Exemplo n.º 1
0
    def from_event(cls, github, type_, id_, event):
        """
        Convert a `GitHub webhook <https://developer.github.com/webhooks/>`_ payload to a
        :class:`.Message`.

        Args:
            github (.GitHubPlug):
                Related plug instance that provides the event.
            type (str):
                Event type name from the ``X-GitHub-Event`` header.
            id (str):
                GUID of the event delivery from the ``X-GitHub-Delivery`` header.
            event (dict):
                GitHub webhook payload.

        Returns:
            .GitHubMessage:
                Parsed message object.
        """
        text = None
        if event["repository"]:
            channel = immp.Channel(github, event["repository"]["full_name"])
            text = cls._repo_text(github, type_, event)
        if not text:
            raise NotImplementedError
        user = GitHubUser.from_sender(github, event["sender"])
        return immp.SentMessage(id_=id_,
                                channel=channel,
                                text=text,
                                user=user,
                                action=True,
                                raw=event)
Exemplo n.º 2
0
 def _replace_ref(self, msg, channel):
     if not isinstance(msg, immp.Receipt):
         log.debug("Not replacing non-receipt message: %r", msg)
         return msg
     base = None
     if isinstance(msg, immp.SentMessage):
         base = immp.Message(text=msg.text, user=msg.user, action=msg.action,
                             reply_to=msg.reply_to, joined=msg.joined, left=msg.left,
                             title=msg.title, attachments=msg.attachments, raw=msg.raw)
     try:
         ref = self._cache[msg]
     except KeyError:
         log.debug("No match for source message: %r", msg)
         return base
     # Given message was a resync of the source message from a synced channel.
     if ref.ids.get(channel):
         log.debug("Found reference to previously synced message: %r", ref.key)
         at = ref.source.at if isinstance(ref.source, immp.Receipt) else None
         best = ref.source or msg
         return immp.SentMessage(id_=ref.ids[channel][0], channel=channel, at=at,
                                 text=best.text, user=best.user, action=best.action,
                                 reply_to=best.reply_to, joined=best.joined, left=best.left,
                                 title=best.title, attachments=best.attachments, raw=best.raw)
     elif channel.plug == msg.channel.plug:
         log.debug("Referenced message has origin plug, not modifying: %r", msg)
         return msg
     else:
         log.debug("Origin message not referenced in the target channel: %r", msg)
         return base
Exemplo n.º 3
0
 async def _timer(self):
     while True:
         await sleep(10)
         log.debug("Creating next test message")
         self.queue(
             immp.SentMessage(id_=self.counter(),
                              channel=self.channel,
                              text="Test",
                              user=self.user))
Exemplo n.º 4
0
    async def from_line(cls, irc, line):
        """
        Convert a :class:`.Line` into a :class:`.Message`.

        Args:
            irc (.IRCPlug):
                Related plug instance that provides the line.
            line (.Line):
                Raw message line from the server.

        Returns:
            .IRCMessage:
                Parsed message object.
        """
        channel = line.args[0]
        nick = line.source.split("!", 1)[0]
        if channel == irc.config["user"]["nick"]:
            # Private messages arrive "from A to B", and should be sent "from B to A".
            channel = nick
        user = IRCUser.from_id(irc, line.source)
        action = False
        joined = []
        left = []
        if line.command == "JOIN":
            text = "joined {}".format(channel)
            action = True
            joined.append(user)
        elif line.command == "PART":
            text = "left {}".format(channel)
            action = True
            left.append(user)
        elif line.command == "KICK":
            target = await irc.user_from_username(line.args[1])
            text = immp.RichText([
                immp.Segment("kicked "),
                immp.Segment(target.username, bold=True),
                immp.Segment(" ({})".format(line.args[2]))
            ])
            action = True
            left.append(target)
        elif line.command == "PRIVMSG":
            text = line.args[1]
            match = re.match(r"\x01ACTION ([^\x01]*)\x01", text)
            if match:
                text = match.group(1)
                action = True
        else:
            raise NotImplementedError
        return immp.SentMessage(id_=Line.next_ts(),
                                channel=immp.Channel(irc, channel),
                                text=text,
                                user=user,
                                action=action,
                                joined=joined,
                                left=left,
                                raw=line)
Exemplo n.º 5
0
 async def put(self, channel, msg):
     # Make a clone of the message to echo back out of the generator.
     clone = immp.SentMessage(id_=self.counter(),
                              channel=self.channel,
                              text=msg.text,
                              user=msg.user,
                              action=msg.action)
     log.debug("Returning message: %r", clone)
     self.queue(clone)
     return [clone]
Exemplo n.º 6
0
    def from_message(cls, discord_, message, edited=False, deleted=False):
        """
        Convert a :class:`discord.Message` into a :class:`.Message`.

        Args:
            discord (.DiscordPlug):
                Related plug instance that provides the event.
            message (discord.Message):
                Discord message object received from a channel.
            edited (bool):
                Whether this message comes from an edit event.
            deleted (bool):
                Whether this message comes from a delete event.

        Returns:
            .DiscordMessage:
                Parsed message object.
        """
        text = None
        channel = immp.Channel(discord_, message.channel.id)
        user = DiscordUser.from_user(discord_, message.author)
        attachments = []
        if message.content:
            text = DiscordRichText.from_markdown(discord_, message.content, channel)
        for attach in message.attachments:
            if attach.filename.endswith((".jpg", ".png", ".gif")):
                type_ = immp.File.Type.image
            elif attach.filename.endswith((".mp4", ".webm")):
                type_ = immp.File.Type.video
            else:
                type_ = immp.File.Type.unknown
            attachments.append(immp.File(title=attach.filename,
                                         type_=type_,
                                         source=attach.url))
        for embed in message.embeds:
            if embed.image.url and embed.image.url.rsplit(".", 1)[1] in ("jpg", "png", "gif"):
                attachments.append(immp.File(type_=immp.File.Type.image,
                                             source=embed.image.url))
        return immp.SentMessage(id_=message.id,
                                channel=channel,
                                # Timestamps are naive but in UTC.
                                at=message.created_at.replace(tzinfo=timezone.utc),
                                # Edited timestamp is blank for new messages, but updated in
                                # existing objects when the message is later edited.
                                revision=(message.edited_at or message.created_at).timestamp(),
                                edited=edited,
                                deleted=deleted,
                                text=text,
                                user=user,
                                attachments=attachments,
                                raw=message)
Exemplo n.º 7
0
    async def from_event(cls, slack, json, parent=True):
        """
        Convert an API event :class:`dict` to a :class:`.Message`.

        Args:
            slack (.SlackPlug):
                Related plug instance that provides the event.
            json (dict):
                Slack API `message <https://api.slack.com/events/message>`_ event data.
            parent (bool):
                ``True`` (default) to retrieve the thread parent if one exists.

        Returns:
            .SlackMessage:
                Parsed message object.
        """
        event = _Schema.message(json)
        if event["hidden"]:
            # Ignore UI-hidden events (e.g. tombstones of deleted files).
            raise NotImplementedError
        if event["is_ephemeral"]:
            # Ignore user-private messages from Slack (e.g. over quota warnings, link unfurling
            # opt-in prompts etc.) which shouldn't be served to message processors.
            raise NotImplementedError
        if event["subtype"] == "file_comment":
            # Deprecated in favour of file threads, but Slack may still emit these.
            raise NotImplementedError
        channel = immp.Channel(slack, event["channel"])
        if event["subtype"] == "message_deleted":
            id_, at = cls._parse_meta(slack, event)
            return immp.SentMessage(id_=id_,
                                    channel=channel,
                                    at=at,
                                    revision=event["ts"],
                                    deleted=True,
                                    raw=json)
        elif event["subtype"] == "message_changed":
            if event["message"]["hidden"]:
                # In theory this should match event["hidden"], but redefined here just in case.
                raise NotImplementedError
            if event["message"]["text"] == event["previous_message"]["text"]:
                # Message remains unchanged.  Can be caused by link unfurling (adds an attachment)
                # or deleting replies (reply is removed from event.replies in new *and old*).
                raise NotImplementedError
            revision = event["ts"]
            # Original message details are under a nested "message" key.
            return await cls._parse_main(slack, json, event["message"],
                                         channel, parent, revision)
        else:
            return await cls._parse_main(slack, json, event, channel, parent)
Exemplo n.º 8
0
    async def send(self, label, msg, origin=None, ref=None):
        """
        Send a message to all channels in this sync.

        Args:
            label (str):
                Bridge that defines the underlying synced channels to send to.
            msg (.Message):
                External message to push.  This should be the source copy when syncing a message
                from another channel.
            origin (.Receipt):
                Raw message that triggered this sync; if set and part of the sync, it will be
                skipped (used to avoid retransmitting a message we just received).  This should be
                the plug-native copy of a message when syncing from another channel.
            ref (.SyncRef):
                Existing sync reference, if message has been partially synced.
        """
        base = immp.Message(text=msg.text, user=msg.user, edited=msg.edited, action=msg.action,
                            reply_to=msg.reply_to, joined=msg.joined, left=msg.left,
                            title=msg.title, attachments=msg.attachments, raw=msg)
        queue = []
        for synced in self.channels[label]:
            if origin and synced == origin.channel:
                continue
            elif ref and ref.ids[synced]:
                log.debug("Skipping already-synced target channel %r: %r", synced, ref)
                continue
            local = base.clone()
            self._replace_recurse(local, self._replace_ref, synced)
            await self._alter_recurse(local, self._alter_identities, synced)
            await self._alter_recurse(local, self._alter_name)
            queue.append(self._send(synced, local))
        # Just like with plugs, when sending a new (external) message to all channels in a sync, we
        # need to wait for all plugs to complete and have their IDs cached before processing any
        # further messages.
        async with self._lock:
            ids = dict(await gather(*queue))
            if ref:
                ref.ids.update(ids)
            else:
                ref = SyncRef(ids, source=msg, origin=origin)
            self._cache.add(ref)
        # Push a copy of the message to the sync channel, if running.
        if self.plug:
            sent = immp.SentMessage(id_=ref.key, channel=immp.Channel(self.plug, label),
                                    text=msg.text, user=msg.user, action=msg.action,
                                    reply_to=msg.reply_to, joined=msg.joined, left=msg.left,
                                    title=msg.title, attachments=msg.attachments, raw=msg)
            self.plug.queue(sent)
        return ref
Exemplo n.º 9
0
Arquivo: irc.py Projeto: Terrance/IMMP
    async def from_line(cls, irc, line):
        """
        Convert a :class:`.Line` into a :class:`.Message`.

        Args:
            irc (.IRCPlug):
                Related plug instance that provides the line.
            line (.Line):
                Raw message line from the server.

        Returns:
            .IRCMessage:
                Parsed message object.
        """
        channel = line.args[0]
        nick = line.source.split("!", 1)[0]
        if channel == irc.config["user"]["nick"]:
            # Private messages arrive "from A to B", and should be sent "from B to A".
            channel = nick
        user = IRCUser.from_id(irc, line.source)
        action = False
        joined = []
        left = []
        if line.command == "JOIN":
            text = "joined {}".format(channel)
            action = True
            joined.append(user)
        elif line.command == "PART":
            text = "left {}".format(channel)
            action = True
            left.append(user)
        elif line.command == "KICK":
            target = await irc.user_from_username(line.args[1])
            text = immp.RichText([immp.Segment("kicked "),
                                  immp.Segment(target.username, bold=True, mention=target),
                                  immp.Segment(" ({})".format(line.args[2]))])
            action = True
            left.append(target)
        elif line.command == "PRIVMSG":
            plain = line.args[1]
            match = re.match(r"\x01ACTION ([^\x01]*)\x01", plain)
            if match:
                plain = match.group(1)
                action = True
            text = immp.RichText()
            puppets = {client.nick: user for user, client in irc._puppets.items()}
            for match in re.finditer(r"[\w\d_\-\[\]{}\|`]+", plain):
                word = match.group(0)
                if word in puppets:
                    target = puppets[word]
                else:
                    target = irc.get_user(word)
                if target:
                    if len(text) < match.start():
                        text.append(immp.Segment(plain[len(text):match.start()]))
                    text.append(immp.Segment(word, mention=target))
            if len(text) < len(plain):
                text.append(immp.Segment(plain[len(text):]))
        else:
            raise NotImplementedError
        return immp.SentMessage(id_=Line.next_ts(),
                                channel=immp.Channel(irc, channel),
                                text=text,
                                user=user,
                                action=action,
                                joined=joined,
                                left=left,
                                raw=line)
Exemplo n.º 10
0
    async def from_event(cls, hangouts, event):
        """
        Convert a :class:`hangups.ChatMessageEvent` into a :class:`.Message`.

        Args:
            hangouts (.HangoutsPlug):
                Related plug instance that provides the event.
            event (hangups.ChatMessageEvent):
                Hangups message event emitted from a conversation.

        Returns:
            .HangoutsMessage:
                Parsed message object.
        """
        user = HangoutsUser.from_user(hangouts,
                                      hangouts._users.get_user(event.user_id))
        action = False
        joined = None
        left = None
        title = None
        attachments = []
        if isinstance(event, hangups.ChatMessageEvent):
            segments = [
                HangoutsSegment.from_segment(segment)
                for segment in event.segments
            ]
            text = immp.RichText(segments)
            if any(a.type == 4 for a in event._event.chat_message.annotation):
                # This is a /me message sent from desktop Hangouts.
                action = True
                # The user's first name prefixes the message text, so try to strip that.
                if user.real_name:
                    # We don't have a clear-cut first name, so try to match parts of names.
                    # Try the full name first, then split successive words off the end.
                    parts = user.real_name.split()
                    start = text[0].text
                    for pos in range(len(parts), 0, -1):
                        sub_name = " ".join(parts[:pos])
                        if start.startswith(sub_name):
                            text[0].text = start[len(sub_name) + 1:]
                            break
                    else:
                        # Couldn't match the user's name to the message text.
                        pass
            for attach in event._event.chat_message.message_content.attachment:
                embed = attach.embed_item
                if any(place in embed.type
                       for place in (hangouts_pb2.ITEM_TYPE_PLACE,
                                     hangouts_pb2.ITEM_TYPE_PLACE_V2)):
                    location = HangoutsLocation.from_embed(embed)
                    if str(text) == (
                            "https://maps.google.com/maps?q={0},{1}".format(
                                location.latitude, location.longitude)):
                        text = None
                    attachments.append(location)
                elif hangouts_pb2.ITEM_TYPE_PLUS_PHOTO in embed.type:
                    attachments.append(await HangoutsFile.from_embed(
                        hangouts, embed))
        elif isinstance(event, hangups.MembershipChangeEvent):
            action = True
            is_join = event.type_ == hangouts_pb2.MEMBERSHIP_CHANGE_TYPE_JOIN
            parts = [
                HangoutsUser.from_user(hangouts,
                                       hangouts._users.get_user(part_id))
                for part_id in event.participant_ids
            ]
            if len(parts) == 1 and parts[0].id == user.id:
                # Membership event is a user acting on themselves.
                segments = [
                    HangoutsSegment("{} the hangout".format(
                        "joined" if is_join else "left"))
                ]
            else:
                segments = [
                    HangoutsSegment("added " if is_join else "removed ")
                ]
                for part in parts:
                    link = "https://hangouts.google.com/chat/person/{}".format(
                        part.id)
                    segments.append(
                        HangoutsSegment(part.real_name, bold=True, link=link))
                    segments.append(HangoutsSegment(", "))
                # Replace trailing comma.
                segments[-1].text = " {} the hangout".format(
                    "to" if is_join else "from")
            if is_join:
                joined = parts
            else:
                left = parts
        elif isinstance(event, hangups.OTREvent):
            action = True
            is_history = (event.new_otr_status ==
                          hangouts_pb2.OFF_THE_RECORD_STATUS_ON_THE_RECORD)
            segments = [
                HangoutsSegment("{}abled hangout message history".format(
                    "en" if is_history else "dis"))
            ]
        elif isinstance(event, hangups.RenameEvent):
            action = True
            title = event.new_name
            segments = [
                HangoutsSegment("renamed the hangout to "),
                HangoutsSegment(event.new_name, bold=True)
            ]
        elif isinstance(event, hangups.GroupLinkSharingModificationEvent):
            action = True
            is_shared = event.new_status == hangouts_pb2.GROUP_LINK_SHARING_STATUS_ON
            segments = [
                HangoutsSegment("{}abled joining the hangout by link".format(
                    "en" if is_shared else "dis"))
            ]
        elif isinstance(event, hangups.HangoutEvent):
            action = True
            texts = {
                hangouts_pb2.HANGOUT_EVENT_TYPE_START: "started a call",
                hangouts_pb2.HANGOUT_EVENT_TYPE_END: "ended the call",
                hangouts_pb2.HANGOUT_EVENT_TYPE_JOIN: "joined the call",
                hangouts_pb2.HANGOUT_EVENT_TYPE_LEAVE: "left the call"
            }
            try:
                segments = [HangoutsSegment(texts[event.event_type])]
            except KeyError:
                raise NotImplementedError
        else:
            raise NotImplementedError
        if not isinstance(event, hangups.ChatMessageEvent):
            text = immp.RichText(segments)
        return immp.SentMessage(id_=event.id_,
                                channel=immp.Channel(hangouts,
                                                     event.conversation_id),
                                at=event.timestamp,
                                text=text,
                                user=user,
                                action=action,
                                joined=joined,
                                left=left,
                                title=title,
                                attachments=attachments,
                                raw=event)
Exemplo n.º 11
0
    def from_event(cls, github, type_, id_, event):
        """
        Convert a `GitHub webhook <https://developer.github.com/webhooks/>`_ payload to a
        :class:`.Message`.

        Args:
            github (.GitHubPlug):
                Related plug instance that provides the event.
            type (str):
                Event type name from the ``X-GitHub-Event`` header.
            id (str):
                GUID of the event delivery from the ``X-GitHub-Delivery`` header.
            event (dict):
                GitHub webhook payload.

        Returns:
            .GitHubMessage:
                Parsed message object.
        """
        repo = event["repository"]["full_name"]
        channel = immp.Channel(github, repo)
        user = GitHubUser.from_sender(github, event["sender"])
        text = None
        if type_ == "push":
            count = len(event["commits"])
            desc = "{} commits".format(
                count) if count > 1 else event["after"][:7]
            ref = event["ref"].split("/")[1:]
            target = "/".join(ref[1:])
            if ref[0] == "tags":
                action, join = "tagged", "as"
            elif ref[0] == "heads":
                action, join = "pushed", "to"
            else:
                raise NotImplementedError
            text = immp.RichText([
                immp.Segment("{} ".format(action)),
                immp.Segment(desc, link=event["compare"]),
                immp.Segment(" {} {} {}".format(join, repo, target))
            ])
            for commit in event["commits"]:
                text.append(
                    immp.Segment("\n* "),
                    immp.Segment(commit["id"][:7], code=True),
                    immp.Segment(" - {}".format(
                        commit["message"].split("\n")[0])))
        elif type_ == "release":
            release = event["release"]
            desc = ("{} ({} {})".format(release["name"], repo,
                                        release["tag_name"])
                    if release["name"] else release["tag_name"])
            text = immp.RichText([
                immp.Segment("{} release ".format(event["action"])),
                immp.Segment(desc, link=release["html_url"])
            ])
        elif type_ == "issues":
            issue = event["issue"]
            desc = "{} ({}#{})".format(issue["title"], repo, issue["number"])
            text = immp.RichText([
                immp.Segment("{} issue ".format(event["action"])),
                immp.Segment(desc, link=issue["html_url"])
            ])
        elif type_ == "issue_comment":
            issue = event["issue"]
            comment = event["comment"]
            desc = "{} ({}#{})".format(issue["title"], repo, issue["number"])
            text = immp.RichText([
                immp.Segment("{} a ".format(event["action"])),
                immp.Segment("comment", link=comment["html_url"]),
                immp.Segment(" on issue "),
                immp.Segment(desc, link=issue["html_url"])
            ])
        elif type_ == "pull_request":
            pull = event["pull_request"]
            desc = "{} ({}#{})".format(pull["title"], repo, pull["number"])
            text = immp.RichText([
                immp.Segment("{} pull request ".format(event["action"])),
                immp.Segment(desc, link=pull["html_url"])
            ])
        elif type_ == "pull_request_review":
            pull = event["pull_request"]
            review = event["review"]
            desc = "{} ({}#{})".format(pull["title"], repo, pull["number"])
            text = immp.RichText([
                immp.Segment("{} a ".format(event["action"])),
                immp.Segment("review", link=review["html_url"]),
                immp.Segment(" on pull request "),
                immp.Segment(desc, link=pull["html_url"])
            ])
        elif type_ == "pull_request_review_comment":
            pull = event["pull_request"]
            comment = event["comment"]
            desc = "{} ({}#{})".format(pull["title"], repo, pull["number"])
            text = immp.RichText([
                immp.Segment("{} a ".format(event["action"])),
                immp.Segment("comment", link=comment["html_url"]),
                immp.Segment(" on pull request "),
                immp.Segment(desc, link=pull["html_url"])
            ])
        elif type_ == "project":
            project = event["project"]
            desc = "{} ({}#{})".format(project["name"], repo,
                                       project["number"])
            text = immp.RichText([
                immp.Segment("{} project ".format(event["action"])),
                immp.Segment(desc, link=project["html_url"])
            ])
        elif type_ == "project_card":
            card = event["project_card"]
            text = immp.RichText([
                immp.Segment("{} ".format(event["action"])),
                immp.Segment("card", link=card["html_url"]),
                immp.Segment(" in project:\n"),
                immp.Segment(card["note"])
            ])
        elif type_ == "gollum":
            text = immp.RichText()
            for i, page in enumerate(event["pages"]):
                if i:
                    text.append(immp.Segment(", "))
                text.append(
                    immp.Segment("{} {} wiki page ".format(
                        page["action"], repo)),
                    immp.Segment(page["title"], link=page["html_url"]))
        elif type_ == "fork":
            fork = event["forkee"]
            text = immp.RichText([
                immp.Segment("forked {} to ".format(repo)),
                immp.Segment(fork["full_name"], link=fork["html_url"])
            ])
        elif type_ == "watch":
            text = immp.RichText([immp.Segment("starred {}".format(repo))])
        if text:
            return immp.SentMessage(id_=id_,
                                    channel=channel,
                                    text=text,
                                    user=user,
                                    action=True,
                                    raw=event)
        else:
            raise NotImplementedError
Exemplo n.º 12
0
 async def _parse_main(cls,
                       slack,
                       json,
                       event,
                       channel,
                       parent=True,
                       revision=None):
     id_, at = cls._parse_meta(slack, event)
     edited = bool(revision)
     deleted = False
     text = event["text"]
     user = await cls._parse_author(slack, event)
     action = False
     reply_to = joined = left = title = None
     attachments = []
     if user and text and re.match(r"<@{}(\|.*?)?> ".format(user.id), text):
         # Own username at the start of the message, assume it's an action.
         action = True
         text = re.sub(r"^<@{}(\|.*?)?> ".format(user.id), "", text)
     if event["subtype"] in ("channel_join", "group_join"):
         action = True
         joined = [user]
     elif event["subtype"] in ("channel_leave", "group_leave"):
         action = True
         left = [user]
     elif event["subtype"] in ("channel_name", "group_name"):
         action = True
         title = event["name"]
     elif event["subtype"] == "me_message":
         action = True
     elif event["subtype"] == "reminder_add":
         action = True
         # Slack leaves a leading space in the message text: " set up a reminder..."
         text = text.lstrip()
     thread = recent = None
     if event["thread_ts"]:
         thread = immp.Receipt(event["thread_ts"],
                               immp.Channel(slack, event["channel"]))
     if thread and parent:
         try:
             thread = await slack.get_message(thread, False)
         except MessageNotFound:
             pass
     if isinstance(thread, immp.Message):
         for reply in thread.raw["replies"][::-1]:
             if reply["ts"] not in (event["ts"], event["thread_ts"]):
                 # Reply to a thread with at least one other message, use the next most
                 # recent rather than the parent.
                 recent = immp.Receipt(reply["ts"], channel)
                 break
     if recent and parent:
         try:
             recent = await slack.get_reply(channel.source,
                                            event["thread_ts"], recent.id,
                                            False)
         except MessageNotFound:
             pass
     if thread and recent:
         # Don't walk the whole thread, just link to the parent after one step.
         recent.reply_to = thread
         reply_to = recent
     else:
         reply_to = recent or thread
     for file in event["files"]:
         try:
             attachments.append(SlackFile.from_file(slack, file))
         except MessageNotFound:
             pass
     for attach in event["attachments"]:
         if attach["is_msg_unfurl"]:
             # We have the message ID as the timestamp, fetch the whole message to embed it.
             try:
                 unfurl = cls.from_unfurl(slack, attach)
                 attachments.append(await slack.resolve_message(unfurl))
             except MessageNotFound:
                 pass
         elif attach["image_url"]:
             attachments.append(
                 immp.File(title=attach["title"],
                           type_=immp.File.Type.image,
                           source=attach["image_url"]))
         elif attach["fallback"]:
             if text:
                 text = "{}\n---\n{}".format(text, attach["fallback"])
             else:
                 text = attach["fallback"]
     if text:
         # Messages can be shared either in the UI, or by pasting an archive link.  The latter
         # unfurls async (it comes through as an edit, which we ignore), so instead we can look
         # up the message ourselves and embed it.
         regex = r"https://{}.slack.com/archives/([^/]+)/p([0-9]+)".format(
             slack._team["domain"])
         for channel_id, link in re.findall(regex, text):
             # Archive links are strange and drop the period from the ts value.
             ts = link[:-6] + "." + link[-6:]
             refs = [
                 attach.id for attach in attachments
                 if isinstance(attach, immp.Receipt)
             ]
             if ts not in refs:
                 try:
                     receipt = immp.Receipt(ts,
                                            immp.Channel(slack, channel_id))
                     attachments.append(await
                                        slack.resolve_message(receipt))
                 except MessageNotFound:
                     pass
         if re.match("^<{}>$".format(regex), text):
             # Strip the message text if the entire body was just a link.
             text = None
         else:
             text = await SlackRichText.from_mrkdwn(slack, text)
     return immp.SentMessage(id_=id_,
                             channel=channel,
                             at=at,
                             revision=revision,
                             edited=edited,
                             deleted=deleted,
                             text=text,
                             user=user,
                             action=action,
                             reply_to=reply_to,
                             joined=joined,
                             left=left,
                             title=title,
                             attachments=attachments,
                             raw=json)