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)
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
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))
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)
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]
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)
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)
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
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)
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)
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
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)