Exemplo n.º 1
0
async def events(event: EventCallback):
    if event.type == 'url_verification':
        # AppにRequest URLを登録した際に初回だけ送信されるURLの検証
        # ref: https://api.slack.com/events/url_verification
        return JSONResponse({'challenge': event.challenge})
    try:
        team_conf = TeamConf.get(event.team_id)
    except TeamConf.DoesNotExist:
        return Response(status_code=HTTPStatus.BAD_REQUEST)
    client = WebClient(team_conf.access_token)
    if event.event:
        if event.event.type == 'reaction_added':
            # 投稿にemojiでリアクションがあったイベントを処理する
            # ref: https://api.slack.com/events/reaction_added
            if event.event.reaction in team_conf.emoji_set:
                # リアクションのemojiが設定されている場合
                if event.event.item:
                    item = event.event.item
                    url = client.chat_getPermalink(
                        channel=item.channel,
                        message_ts=item.ts).get('permalink')
                    blocks = [
                        SectionBlock(text=MarkdownTextObject(text=f'<{url}>')),
                        ActionsBlock(elements=[
                            ButtonElement(text='読んだ',
                                          action_id='mark_as_read',
                                          value='mark_as_read')
                        ])
                    ]
                    client.chat_postMessage(text=url,
                                            channel=event.event.user,
                                            unfurl_links=True,
                                            blocks=blocks)
    return Response()
Exemplo n.º 2
0
class CountStamp:
    startdate = None
    startdatetime = None
    enddatetime = None
    stamp_counter = {}
    user_counter = {}
    recv_counter = {}
    client = None
    bot = None
    message = ""
    chat_list = []

    # インストラクタ
    def __init__(self):
        print("CountStamp Start")
        self.client = WebClient(const.USER_TOKEN)
        self.bot = WebClient(const.BOT_TOKEN)
        #Start of time
        self.startdate = datetime.date.today() - datetime.timedelta(
            days=const.DAYS_AGO)
        self.startdatetime = datetime.datetime.combine(self.startdate,
                                                       datetime.time())
        self.enddatetime = self.startdatetime + datetime.timedelta(
            days=const.DAYS_TERM)
        print("term:{}-{}".format(self.startdatetime, self.enddatetime))

    # チャンネルリストを取得する
    def setChannelList(self):
        self.channel_list = self.client.conversations_list(
            exclude_archived=True, limit=const.CHANNEL_LIMIT)
        print("channels:{}".format(len(self.channel_list["channels"])))

    # スタンプをカウントする
    def cntStamp(self):
        channel_cnt = 0
        for channel in self.channel_list["channels"]:
            channel_cnt = channel_cnt + 1
            channel_id = channel["id"]
            #apiの呼び出し回数の制限(1分間に50回まで)を回避する
            time.sleep(1)
            history = self.client.conversations_history(
                channel=channel_id,
                oldest=self.startdatetime.timestamp(),
                latest=self.enddatetime.timestamp())
            #履歴内のスタンプをカウントする
            self.cntReactions(history=history, channel=channel)

    # 履歴内のスタンプをカウントする
    def cntReactions(self, history, channel):
        if (len(history['messages']) > 0):
            print("channel_name:{} messages:{}".format(
                channel['name'], len(history['messages'])))
        for message in history["messages"]:
            try:
                if (message.get("reactions")):
                    reactions_cnt = 0
                    for reaction in message["reactions"]:
                        # ユーザー別のスタンプ数のカウント
                        self.cntUsers(users=reaction["users"])

                        # スタンプ別のスタンプ数のカウント
                        key = reaction["name"]
                        if (self.stamp_counter.get(key)):
                            self.stamp_counter[key] = self.stamp_counter[
                                key] + reaction["count"]
                        else:
                            self.stamp_counter[key] = reaction["count"]

                        # スタンプを受け取ったユーザー別のスタンプ数のカウント
                        if (message.get("user")):
                            key = message["user"]
                            if (self.recv_counter.get(key)):
                                self.recv_counter[key] = self.recv_counter[
                                    key] + reaction["count"]
                            else:
                                self.recv_counter[key] = reaction["count"]

                        # スレッドについたスタンプ数のカウント
                        reactions_cnt = reactions_cnt + reaction["count"]

                    # スレッド別のスタンプ数
                    self.chat_list.append(
                        [channel['id'], message["ts"], reactions_cnt])
            except KeyError as e:
                print("KeyError:")
                print(e.args)

    #スタンプしたユーザーをカウント
    def cntUsers(self, users):
        for user_id in users:
            if (self.user_counter.get(user_id)):
                self.user_counter[user_id] = self.user_counter[user_id] + 1
            else:
                self.user_counter[user_id] = 1

    # カウントをポストする
    def setMessage(self):
        sorted_stamp = sorted(self.stamp_counter.items(),
                              key=lambda x: x[1],
                              reverse=True)
        sorted_user = sorted(self.user_counter.items(),
                             key=lambda x: x[1],
                             reverse=True)
        sorted_recv = sorted(self.recv_counter.items(),
                             key=lambda x: x[1],
                             reverse=True)
        sorted_chat = sorted(self.chat_list, key=lambda x: x[2], reverse=True)

        w_list = ['月', '火', '水', '木', '金', '土', '日']
        self.message = "{}({})のスタンプランキングTOP{}を発表します。\n".format(
            self.startdate.strftime('%Y年%m月%d日'),
            w_list[self.startdate.weekday()], const.RANK_LIMIT)

        self.message = self.message + "\n\n:+1:このスタンプが良く使われました:+1:\n"
        self.setRankingMessage(sorted_stamp, False)

        self.message = self.message + "\n\n:tera_感謝_赤:このユーザーがたくさんスタンプしました:tera_感謝_赤:\n"
        self.setRankingMessage(sorted_user, True)

        self.message = self.message + "\n\n:gift:このユーザーがたくさんスタンプを受け取りました:gift:\n"
        self.setRankingMessage(sorted_recv, True)

        self.message = self.message + "\n\n:trophy:スタンプを集めたメッセージはこちら:trophy:\n"
        self.setChatRankingMessage(sorted_chat)

        total_stamp = sum(self.stamp_counter.values())
        self.message = self.message + "\n\nすべてのスタンプを合計すると {} でした!".format(
            total_stamp)
        i = 1
        while i <= int(total_stamp / const.CLAP_LOOP):
            self.message = self.message + ":clap:"
            i = i + 1

        today_weekday = datetime.date.today().weekday()
        # 土日はメッセージを変える。
        if (today_weekday != 5 and today_weekday != 6):
            self.message = self.message + "\nそれでは今日もはりきってスタンプしましょう!"
        else:
            self.message = self.message + "\n休日対応おつかれさまです。"

    def postMessage(self):
        self.bot.chat_postMessage(channel=const.CHANNEL_NAME,
                                  text=self.message)

    #ランキング処理
    def setRankingMessage(self, rank_list, user_flag):
        rank = 1
        i = 0
        while i < len(rank_list):
            if (user_flag):
                self.message = self.message + '\n{}位 {} {}'.format(
                    rank, self.getUsername(rank_list[i][0]), rank_list[i][1])
            else:
                self.message = self.message + '\n{}位 :{}: {}'.format(
                    rank, rank_list[i][0], rank_list[i][1])
            #同列順位の処理
            j = 1
            while (i + j) < len(rank_list):
                if (rank_list[i][1] == rank_list[i + j][1]):
                    if (user_flag):
                        self.message = self.message + '  {} {}'.format(
                            self.getUsername(rank_list[i + j][0]),
                            rank_list[i + j][1])
                    else:
                        self.message = self.message + '  :{}: {}'.format(
                            rank_list[i + j][0], rank_list[i + j][1])
                    j = j + 1
                else:
                    break
            i = i + j
            rank = rank + j
            if (rank > const.RANK_LIMIT):
                break
        self.message = self.message + '\n'

    #チャットのランク処理
    def setChatRankingMessage(self, sorted_chat):
        rank = 1
        for chat in sorted_chat:
            link = self.bot.chat_getPermalink(channel=chat[0],
                                              message_ts=chat[1])
            self.message = self.message + link["permalink"] + '\n'
            rank = rank + 1
            if (rank > const.CHAT_RANK_LIMIT):
                break

    #ユーザーの表示名を取得する
    def getUsername(self, user_id):
        user_info = self.bot.users_info(user=user_id)
        return user_info['user']['profile']['real_name']

    # デストラクタ
    def __del__(self):
        print("CountStamp End")
Exemplo n.º 3
0
class CSDigest:
    def __init__(self, config_path) -> None:
        print("loading data")
        # handling files
        with open(config_path) as cfg:
            config = yaml.load(cfg, Loader=yaml.FullLoader)
        with open(config["token_path"]) as tk:
            self.token = tk.readline()
        with open(config["template_path"]) as tp:
            self.temp = BeautifulSoup(tp, "html.parser")
        self.cache_im = os.path.join("cache", "images")
        os.makedirs(self.cache_im, exist_ok=True)
        self.ts_old = dateparser.parse(config["time_span"]).timestamp()
        # initialize objects
        self.foodnet = models.load_model("./foodnet/model")
        self.datagen = ImageDataGenerator(rescale=1.0 / 255)
        self.client = WebClient(token=self.token)
        self.channels = pd.DataFrame(
            self.client.conversations_list(limit=1000)["channels"]
        ).set_index("name")
        self.users = pd.DataFrame(
            self.client.users_list(limit=1000)["members"]
        ).set_index("id")
        self.users["display_name"] = self.users["profile"].apply(
            self.extract_profile, key="display_name"
        )
        print("fetching messages")
        # get messages
        ms_general = self.get_msg("general", same_user=False)
        ms_home = self.get_msg("homesanity", ts_thres=0)
        ms_quote = self.get_msg("quotablequotes", ts_thres=120, same_user=False)
        print("building newsletter")
        # handle carousel
        if len(ms_general) > 0:
            ms_general["class"] = ms_general.apply(self.classify_msg, axis="columns")
            ms_tada = ms_general[ms_general["class"] == "tada"]
            if len(ms_tada) > 0:
                ms_tada["permalink"] = ms_tada.apply(self.get_permalink, axis="columns")
                self.build_carousel(ms_tada)
        # handle food
        ms_files = pd.concat([ms_general, ms_home])
        if len(ms_files) > 0:
            ms_files["file_path"] = ms_files.apply(self.download_images, axis="columns")
            ms_files = ms_files[ms_files["file_path"].astype(bool)]
            ms_files["food_prob"] = ms_files["file_path"].apply(self.classify_food)
            ms_files["food_path"] = ms_files.apply(self.filter_food, axis="columns")
            ms_food = ms_files[ms_files["food_path"].notnull()]
            ms_food["permalink"] = ms_food.apply(self.get_permalink, axis="columns")
            ms_food["aspect"] = ms_food["food_path"].apply(self.get_img_aspect)
            ms_food = ms_food.sort_values("aspect", ascending=True)
            self.build_portfolio(ms_food)
        # handle quotes
        if len(ms_quote) > 0:
            ms_quote = ms_quote[~ms_quote["files"].astype(bool)]
            ms_quote["permalink"] = ms_quote.apply(self.get_permalink, axis="columns")
            self.build_quotes(ms_quote)

    def get_msg(self, channel, ts_thres=5, same_user=True):
        ms = pd.DataFrame(
            self.client.conversations_history(
                channel=self.channels.loc[channel]["id"],
                oldest=self.ts_old,
                limit=1000,
            )["messages"]
        )
        if len(ms) > 0:
            try:
                ms = ms[ms["subtype"].isnull()]
            except KeyError:
                pass
            if len(ms) > 0:
                ms = (
                    self.cluster_msg(ms, ts_thres=ts_thres, same_user=same_user)
                    .groupby("component")
                    .apply(self.merge_msg)
                    .reset_index()
                    .apply(self.translate_msg_user, axis="columns")
                )
                ms["text"] = ms["text"].apply(
                    emojize, use_aliases=True, variant="emoji_type"
                )
                ms["channel"] = self.channels.loc[channel]["id"]
        return ms

    def classify_msg(self, msg_df):
        if msg_df["reactions"]:
            tada = list(filter(lambda r: r["name"] == "tada", msg_df["reactions"]))
            if tada and tada[0]["count"] > 5:
                return "tada"

    def cluster_msg(self, msg_df, ts_thres, same_user):
        ts_dist = pdist(msg_df["ts"].values.astype(float).reshape((-1, 1)))
        txt_dist = pdist(CountVectorizer().fit_transform(msg_df["text"]).toarray())
        adj = squareform(ts_dist) < ts_thres * 60
        if same_user:
            user_dist = pdist(
                msg_df["user"].values.reshape((-1, 1)),
                metric=lambda u, v: 0 if u == v else 1,
            )
            adj = adj * (squareform(user_dist) < 1)
        n_comp, lab = connected_components(adj, directed=False)
        msg_df["component"] = lab
        return msg_df

    def merge_msg(self, msg_df, multiple_users="first"):
        msg_df = msg_df.sort_values("ts")
        if multiple_users == "forbid":
            user = msg_df["user"].unique()
            assert len(user) == 1
            user = user.item()
        elif multiple_users == "first":
            user = msg_df.iloc[0]["user"]
            msg_df = msg_df[msg_df["user"] == user]
        else:
            raise ValueError("multiple_users=={} not understood".format(multiple_users))
        try:
            reactions = msg_df["reactions"].dropna().values
            reactions = sum(reactions, [])
        except KeyError:
            reactions = []
        try:
            files = msg_df["files"].dropna().values
            files = sum(files, [])
        except KeyError:
            files = []
        try:
            attch = msg_df["attachments"].dropna().values
            attch = sum(attch, [])
        except KeyError:
            attch = []
        return pd.Series(
            {
                "user": user,
                "text": "\n".join(msg_df["text"].values),
                "ts": msg_df.iloc[0].loc["ts"],
                "reactions": reactions,
                "files": files,
                "attachments": attch,
            }
        )

    def translate_msg_user(self, msg_row, substitute=["display_name", "name"]):
        try:
            msg_row["user"] = self.translate_user(msg_row["user"], substitute)
            msg_row["text"] = re.sub(
                r"\<\@(.*?)\>",
                lambda u: self.translate_user(u.group(1), substitute),
                msg_row["text"],
            )
        except TypeError:
            pass
        return msg_row

    def translate_user(self, uid, substitute):
        for sub in substitute:
            if sub == "real_name":
                prefix = ""
            else:
                prefix = "@"
            user = self.users.loc[uid, sub]
            if type(user) == str and bool(user):
                return prefix + user

    def extract_profile(self, prof, key):
        try:
            return prof[key]
        except KeyError:
            return np.nan

    def get_permalink(self, msg_row):
        return self.client.chat_getPermalink(
            channel=msg_row["channel"], message_ts=str(msg_row["ts"])
        )["permalink"]

    def download_images(self, msg_row):
        fpaths = []
        for fdict in msg_row["files"]:
            try:
                mimietype = fdict["mimetype"]
            except KeyError:
                continue
            if mimietype.startswith("image"):
                fpath = os.path.join(
                    self.cache_im,
                    ".".join([fdict["id"], fdict["filetype"]]),
                )
                resp = requests.get(
                    fdict["url_private_download"],
                    headers={"Authorization": "Bearer {}".format(self.token)},
                )
                open(fpath, "wb").write(resp.content)
                fpaths.append(fpath)
        for fdict in msg_row["attachments"]:
            try:
                url = fdict["image_url"]
            except KeyError:
                continue
            fpath = os.path.join(self.cache_im, url.split("/")[-1].split("?")[0])
            resp = requests.get(url)
            open(fpath, "wb").write(resp.content)
            fpaths.append(fpath)
        return fpaths

    def build_carousel(self, msg_df):
        indicator = self.temp.find("ol", {"id": "carousel-inds"})
        ind_temp = indicator.find("li", {"id": "carousel-ind-template"}).extract()
        sld_wrapper = self.temp.find("div", {"id": "carousel-slides"})
        tada_temp = self.temp.find("div", {"id": "carousel-slide-template"}).extract()
        for (imsg, msg), icss in zip(
            msg_df.reset_index(drop=True).iterrows(), itt.cycle(np.arange(3) + 1)
        ):
            cur_ind = copy.copy(ind_temp)
            cur_ind["data-slide-to"] = str(imsg)
            cur_tada = copy.copy(tada_temp)
            cur_tada.find("h3", {"id": "carousel-slide-message"}).string = (
                msg["text"] if len(msg["text"]) <= 320 else msg["text"][:320] + "..."
            )
            cur_tada.find(True, {"id": "carousel-slide-author"}).string = " ".join(
                [
                    msg["user"],
                    datetime.fromtimestamp(float(msg["ts"])).strftime("%b %d"),
                ]
            )
            cur_tada.find("a")["href"] = msg["permalink"]
            if re.search("birthday", msg["text"].lower()):
                cur_tada["class"] = [
                    "carousel-birthday" if c == "carousel-tada-1" else c
                    for c in cur_tada["class"]
                ]
            else:
                cur_tada["class"] = [
                    "carousel-tada-{}".format(icss) if c == "carousel-tada-1" else c
                    for c in cur_tada["class"]
                ]
            if not imsg == 0:
                del cur_ind["class"]
                cur_tada["class"] = list(
                    filter(lambda c: c != "active", cur_tada["class"])
                )
            indicator.append(cur_ind)
            sld_wrapper.append(cur_tada)

    def write_html(self):
        with open("csdigest.html", "w", encoding="utf-8") as outf:
            outf.write(str(self.temp))

    def classify_food(self, img_path):
        try:
            imgs = [img_to_array(load_img(imp).resize((512, 512))) for imp in img_path]
        except:
            return np.atleast_1d(np.ones(len(img_path))).tolist()
        predict = self.foodnet.predict(self.datagen.flow(np.stack(imgs)))
        return np.atleast_1d(predict.squeeze()).tolist()

    def filter_food(self, msg_row, thres=0.1):
        minval, minidx = np.min(msg_row["food_prob"]), np.argmin(msg_row["food_prob"])
        if minval < thres:
            return msg_row["file_path"][minidx]
        else:
            return np.nan

    def get_img_aspect(self, path):
        img = load_img(path)
        return img.size[0] / img.size[1]

    def build_portfolio(self, msg_df):
        porto = self.temp.find("div", {"id": "portfolio-container"})
        port_temp = self.temp.find("div", {"id": "portfolio-template"}).extract()
        del port_temp["id"]
        for imsg, msg in msg_df.iterrows():
            cur_temp = copy.copy(port_temp)
            cur_temp.img["src"] = msg["food_path"]
            cur_temp.find("a", {"id": "port-zoom-link"})["href"] = msg["food_path"]
            cur_temp.find("a", {"id": "port-msg-link"})["href"] = msg["permalink"]
            txt = msg["text"]
            if len(txt) > 150:
                txt = txt[:150] + "..."
            cur_temp.find(True, {"id": "port-item-text"}).string = txt
            # cur_temp.find(True, {"id": "port-item-text"}).string = str(
            #     np.min(msg["food_prob"])
            # )
            porto.append(cur_temp)

    def build_quotes(self, msg_df):
        quotes = self.temp.find("div", {"id": "quote-block"})
        quote_temp = quotes.find("div", {"id": "quote-template"}).extract()
        indicators = self.temp.find("ol", {"id": "quote-indicator-wrap"})
        ind_temp = indicators.find("li", {"id": "quote-indicator"}).extract()
        for imsg, msg in msg_df.reset_index(drop=True).iterrows():
            cur_quote = copy.copy(quote_temp)
            cur_quote.find("a", {"id": "quote-link"})["href"] = msg["permalink"]
            cur_quote.find("p", {"id": "quote-content"}).string = (
                msg["text"] if len(msg["text"]) <= 400 else msg["text"][:400] + "..."
            )
            cur_quote.find("span", {"id": "quote-name"}).string = msg["user"]
            cur_ind = copy.copy(ind_temp)
            cur_ind["data-slide-to"] = str(imsg)
            if imsg > 0:
                cur_quote["class"] = list(
                    filter(lambda c: c != "active", cur_quote["class"])
                )
                cur_ind["class"] = list(
                    filter(lambda c: c != "active", cur_ind["class"])
                )
            quotes.append(cur_quote)
            indicators.append(cur_ind)
Exemplo n.º 4
0
class Slack:
    def __init__(self, token, channel_name=None, channel_id=None):
        self.client = WebClient(token)
        self.channel_id = channel_id

        channels = self.client.conversations_list(
            types='public_channel,private_channel')

        if channel_name:
            for channel in channels['channels']:
                if channel['name'] == channel_name:
                    self.channel_id = channel['id']
                    break
        if not self.channel_id:
            self.channel_id = self.client.conversations_create(
                name=channel_name.lower(), is_private=True)['channel']['id']
            admins = [
                u['id'] for u in self.client.users_list()['members']
                if u.get('is_admin') or u.get('is_owner')
            ]
            self.client.conversations_invite(channel=self.channel_id,
                                             users=admins)

    def send_snippet(self,
                     title,
                     initial_comment,
                     code,
                     code_type='python',
                     thread_ts=None):
        return self.client.files_upload(
            channels=self.channel_id,
            title=title,
            initial_comment=initial_comment.replace('<br>', ''),
            content=code,
            filetype=code_type,
            thread_ts=thread_ts)['ts']

    def send_exception_snippet(self,
                               domain,
                               event,
                               code_type='python',
                               thread_ts=None):
        message = traceback.format_exc() + '\n\n\n' + dumps(event, indent=2)
        subject = 'Error occurred in ' + domain
        self.send_snippet(subject,
                          subject,
                          message,
                          code_type=code_type,
                          thread_ts=thread_ts)

    def send_raw_message(self, blocks, thread_ts=None):
        return self.client.chat_postMessage(channel=self.channel_id,
                                            blocks=blocks,
                                            thread_ts=thread_ts)['ts']

    def update_raw_message(self, ts, blocks):
        self.client.chat_update(channel=self.channel_id, blocks=blocks, ts=ts)

    def get_perm_link(self, ts):
        return self.client.chat_getPermalink(channel=self.channel_id,
                                             message_ts=ts)['permalink']

    def send_message(self, message, attachment=None, thread_ts=None):
        blocks = [{
            'type': 'section',
            'text': {
                'type': 'mrkdwn',
                'text': message.replace('<br>', '')
            }
        }, {
            'type': 'divider'
        }]
        if attachment:
            blocks[0]['accessory'] = {
                'type': 'button',
                'text': {
                    'type': 'plain_text',
                    'text': attachment['text'],
                    'emoji': True
                },
                'url': attachment['value']
            }

        return self.send_raw_message(blocks, thread_ts)
Exemplo n.º 5
0
class SlackMessaging:
    """A class used to parse in-game messages and send them to Slack."""

    def __init__(
        self,
        slack_bot_token: str,
        slack_channel_id: str,
    ) -> None:
        """Class constructor."""
        super().__init__()
        self.slack_channel_id = slack_channel_id
        self.slack_client = WebClient(token=slack_bot_token)
        self.logger = logging.getLogger(__name__)

    def send_slack_message(
        self, text: str, sender: Dict, parent: str = None
    ) -> SlackResponse:
        """Send Slack message to the configured channel."""
        try:
            icon = sender["icons"]["league"]
            if sender["icons"]["race"]:
                icon = sender["icons"]["race"]
            elif sender["icons"]["player"]:
                icon = sender["icons"]["player"]

            # Call the chat.postMessage method using the WebClient
            result = self.slack_client.chat_postMessage(
                channel=self.slack_channel_id,
                text=text,
                username=sender["name"],
                icon_url=icon,
                thread_ts=parent,
            )
            self.logger.debug(result)

            if parent:
                time.sleep(1)
                child_pl = self.slack_client.chat_getPermalink(
                    channel=self.slack_channel_id, message_ts=result["ts"]
                )
                text = f"<{child_pl['permalink']}|New message in thread>"
                channel_result = self.slack_client.chat_postMessage(
                    channel=self.slack_channel_id,
                    text=text,
                    username=sender["name"],
                    icon_url=icon,
                )
                self.logger.debug(channel_result)

            return result

        except SlackApiError as err:
            self.logger.error("Error posting message: %s", err)
            raise

    def send_new_messages_to_slack(
        self, messages: List, planets_game_id: str, mbox_path: Path = None
    ) -> None:
        """Fetch messages from a game and send new ones to Slack."""
        if not mbox_path:
            mbox_path = Path(f"messages-{planets_game_id}.mbox")
        mbox = Mbox(mbox_path)
        message_ids = mbox.get_message_ids()

        for msg in messages:
            slack_thread_id = None
            # Check that we haven't sent the message earlier, and filter out
            # some spammy messages generated by the system.
            if msg["msgid"] not in message_ids:
                if not (
                    msg["sourcename"] == msg["gamename"]
                    and len(msg["replies"]) == 0
                    and re.search(
                        "joined the game in slot|earned the award", msg["message"]
                    )
                ):
                    # Let's not overwhelm Slack API
                    time.sleep(2)

                    # Construct the Slack message and send it
                    slack_resp = self.send_slack_message(
                        self.construct_slack_message(msg),
                        msg["sender"],
                    )

                    if slack_resp:
                        self.logger.info(
                            "Message %s sent to Slack successfully.", msg["msgid"]
                        )
                        slack_thread_id = slack_resp["ts"]
                        if mbox.save_email_message(msg, slack_thread_id):
                            message_ids[msg["msgid"]] = slack_thread_id
            else:
                self.logger.debug("Message %s already sent.", msg["msgid"])
                slack_thread_id = message_ids[msg["msgid"]]

            for reply in sorted(msg["replies"], key=lambda x: x["dateadded"]):
                if reply["msgid"] not in message_ids:
                    # Let's not overwhelm Slack API
                    time.sleep(2)

                    # Construct the Slack message and send it
                    slack_reply_resp = self.send_slack_message(
                        self.construct_slack_message(reply),
                        reply["sender"],
                        slack_thread_id,
                    )

                    if slack_reply_resp:
                        self.logger.info(
                            "Reply %s (parent %s) sent to Slack successfully.",
                            reply["msgid"],
                            msg["msgid"],
                        )
                        if mbox.save_email_message(reply, slack_reply_resp["ts"]):
                            message_ids[reply["msgid"]] = slack_thread_id
                else:
                    self.logger.debug(
                        "Reply %s (parent %s) already sent.",
                        reply["msgid"],
                        msg["msgid"],
                    )

    @staticmethod
    def construct_slack_message(message: Dict) -> str:
        """Construct a Slack message from an in-game message."""
        turn_str = f'*Turn {message["turn"]}*'
        to_str = f'*To:* {", ".join(message["recipients"].keys())}'
        date_str = f'*Date*: {message["dateadded"]}'
        body_str = message["message"].replace("<br/>", "\n")
        return f"{turn_str}\n{to_str}\n{date_str}\n\n{body_str}"