diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 34bedb7..c7106f3 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -40,6 +40,29 @@ jobs: pre-commit run -av check-yaml pre-commit run -av check-added-large-files + test: + name: Run the tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: | + pip install -r dev-requirements.txt + + - name: Run pytest + run: | + pytest -vv + pytest -vv > pytest-coverage.txt + + - name: Comment coverage + uses: coroo/pytest-coverage-commentator@v1.0.2 + if: ${{ github.event_name == 'pull_request' && github.event.action == 'created' }} + build-docker: runs-on: ubuntu-latest steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d7ef594..b8bbd45 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: rev: 5.12.0 hooks: - id: isort - files: ^(cicd|linkedin_matrix)/.*\.pyi?$ + files: ^(cicd|linkedin_matrix|linkedin_messaging|tests)/.*\.pyi?$ # flake8 - repo: https://github.com/pycqa/flake8 diff --git a/dev-requirements.txt b/dev-requirements.txt index d4a0a1d..4601559 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -5,4 +5,5 @@ flake8-print>=5.0.0,<6 flake8>=6.0.0,<7 isort>=5.10.1,<6 pre-commit>=2.10.1,<4 +pytest>=7.4.3,<7.5 termcolor>=2.1.1,<3 diff --git a/linkedin_matrix/db/message.py b/linkedin_matrix/db/message.py index 51fc158..3e11d04 100644 --- a/linkedin_matrix/db/message.py +++ b/linkedin_matrix/db/message.py @@ -5,8 +5,8 @@ from asyncpg import Record from attr import dataclass -from linkedin_messaging import URN +from linkedin_messaging import URN from mautrix.types import EventID, RoomID from mautrix.util.async_db import Scheme diff --git a/linkedin_matrix/db/portal.py b/linkedin_matrix/db/portal.py index 1f633e4..401f52e 100644 --- a/linkedin_matrix/db/portal.py +++ b/linkedin_matrix/db/portal.py @@ -4,8 +4,8 @@ from asyncpg import Record from attr import dataclass -from linkedin_messaging import URN +from linkedin_messaging import URN from mautrix.types import ContentURI, RoomID from .model_base import Model diff --git a/linkedin_matrix/db/puppet.py b/linkedin_matrix/db/puppet.py index aa543a1..1afc2ae 100644 --- a/linkedin_matrix/db/puppet.py +++ b/linkedin_matrix/db/puppet.py @@ -4,9 +4,9 @@ from asyncpg import Record from attr import dataclass -from linkedin_messaging import URN from yarl import URL +from linkedin_messaging import URN from mautrix.types import ContentURI, SyncToken, UserID from .model_base import Model diff --git a/linkedin_matrix/db/reaction.py b/linkedin_matrix/db/reaction.py index c683eb0..02d3a37 100644 --- a/linkedin_matrix/db/reaction.py +++ b/linkedin_matrix/db/reaction.py @@ -2,8 +2,8 @@ from asyncpg import Record from attr import dataclass -from linkedin_messaging import URN +from linkedin_messaging import URN from mautrix.types import EventID, RoomID from .model_base import Model diff --git a/linkedin_matrix/db/user.py b/linkedin_matrix/db/user.py index 01c168c..9a88adb 100644 --- a/linkedin_matrix/db/user.py +++ b/linkedin_matrix/db/user.py @@ -4,8 +4,8 @@ from asyncpg import Record from attr import dataclass -from linkedin_messaging import URN +from linkedin_messaging import URN from mautrix.types import RoomID, UserID from .model_base import Model diff --git a/linkedin_matrix/formatter/from_linkedin.py b/linkedin_matrix/formatter/from_linkedin.py index df03fff..576b4d1 100644 --- a/linkedin_matrix/formatter/from_linkedin.py +++ b/linkedin_matrix/formatter/from_linkedin.py @@ -3,9 +3,9 @@ from html import escape from bs4 import BeautifulSoup + from linkedin_messaging import URN from linkedin_messaging.api_objects import AttributedBody, SpInmailContent - from mautrix.types import Format, MessageType, TextMessageEventContent from .. import puppet as pu, user as u diff --git a/linkedin_matrix/formatter/from_matrix.py b/linkedin_matrix/formatter/from_matrix.py index d28a1fa..46a37e3 100644 --- a/linkedin_matrix/formatter/from_matrix.py +++ b/linkedin_matrix/formatter/from_matrix.py @@ -8,7 +8,6 @@ MessageCreate, TextEntity, ) - from mautrix.appservice import IntentAPI from mautrix.types import Format, MessageType, TextMessageEventContent from mautrix.util.formatter import ( diff --git a/linkedin_matrix/portal.py b/linkedin_matrix/portal.py index ad17203..c90b25d 100644 --- a/linkedin_matrix/portal.py +++ b/linkedin_matrix/portal.py @@ -8,6 +8,9 @@ import asyncio from bs4 import BeautifulSoup +import magic + +from linkedin_matrix.db.message import Message from linkedin_messaging import URN from linkedin_messaging.api_objects import ( AttributedBody, @@ -22,9 +25,6 @@ RealTimeEventStreamEvent, ThirdPartyMedia, ) -import magic - -from linkedin_matrix.db.message import Message from mautrix.appservice import IntentAPI from mautrix.bridge import BasePortal, NotificationDisabler, async_getter_lock from mautrix.errors import MatrixError, MForbidden diff --git a/linkedin_matrix/puppet.py b/linkedin_matrix/puppet.py index 3320fee..c3ede2f 100644 --- a/linkedin_matrix/puppet.py +++ b/linkedin_matrix/puppet.py @@ -4,12 +4,12 @@ from datetime import datetime import re -from linkedin_messaging import URN -from linkedin_messaging.api_objects import MessagingMember, Picture from yarl import URL import aiohttp import magic +from linkedin_messaging import URN +from linkedin_messaging.api_objects import MessagingMember, Picture from mautrix.appservice import IntentAPI from mautrix.bridge import BasePuppet, async_getter_lock from mautrix.types import ContentURI, SyncToken, UserID diff --git a/linkedin_matrix/user.py b/linkedin_matrix/user.py index 5b9ba12..adbaa22 100644 --- a/linkedin_matrix/user.py +++ b/linkedin_matrix/user.py @@ -8,6 +8,7 @@ import time from aiohttp.client_exceptions import ServerConnectionError, TooManyRedirects + from linkedin_messaging import URN, LinkedInMessaging from linkedin_messaging.api_objects import ( Conversation, @@ -16,7 +17,6 @@ RealTimeEventStreamEvent, UserProfileResponse, ) - from mautrix.bridge import BaseUser, async_getter_lock from mautrix.errors import MNotFound from mautrix.types import EventType, PushActionType, PushRuleKind, PushRuleScope, RoomID, UserID diff --git a/linkedin_messaging/__init__.py b/linkedin_messaging/__init__.py new file mode 100644 index 0000000..280e423 --- /dev/null +++ b/linkedin_messaging/__init__.py @@ -0,0 +1,6 @@ +"""An unofficial API for interacting with LinkedIn Messaging""" + +from .api_objects import URN +from .linkedin import ChallengeException, LinkedInMessaging + +__all__ = ("ChallengeException", "LinkedInMessaging", "URN") diff --git a/linkedin_messaging/api_objects.py b/linkedin_messaging/api_objects.py new file mode 100644 index 0000000..7039fd1 --- /dev/null +++ b/linkedin_messaging/api_objects.py @@ -0,0 +1,577 @@ +from typing import Any, Callable, Optional, Union +from dataclasses import dataclass, field +from datetime import datetime + +from dataclasses_json import DataClassJsonMixin, LetterCase, Undefined, config, dataclass_json +import dataclasses_json + + +class URN: + def __init__(self, urn_str: str): + urn_parts = urn_str.split(":") + self.prefix = ":".join(urn_parts[:-1]) + self.id_parts = urn_parts[-1].strip("()").split(",") + + def get_id(self) -> str: + assert len(self.id_parts) == 1 + return self.id_parts[0] + + def id_str(self) -> str: + return ",".join(self.id_parts) + + def __str__(self) -> str: + return "{}:{}".format( + self.prefix, + (self.id_parts[0] if len(self.id_parts) == 1 else f"({self.id_str()})"), + ) + + def __hash__(self) -> int: + return hash(self.id_str()) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, URN): + return False + return self.id_parts == other.id_parts + + def __repr__(self) -> str: + return f"URN('{str(self)}')" + + +# Use milliseconds instead of seconds from the UNIX epoch. +decoder_functions = { + datetime: (lambda s: datetime.utcfromtimestamp(int(s) / 1000) if s else None), + URN: (lambda s: URN(s) if s else None), +} +encoder_functions: dict[Any, Callable[[Any], Any]] = { + datetime: (lambda d: int(d.timestamp() * 1000) if d else None), + URN: (lambda u: str(u) if u else None), +} + +for type_, translation_function in decoder_functions.items(): + dataclasses_json.cfg.global_config.decoders[type_] = translation_function + dataclasses_json.cfg.global_config.decoders[ + Optional[type_] # type: ignore + ] = translation_function + +for type_, translation_function in encoder_functions.items(): + dataclasses_json.cfg.global_config.encoders[type_] = translation_function + dataclasses_json.cfg.global_config.encoders[ + Optional[type_] # type: ignore + ] = translation_function + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class Artifact: + height: int = -1 + width: int = -1 + file_identifying_url_path_segment: str = "" + expires_at: Optional[datetime] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class VectorImage: + artifacts: list[Artifact] = field(default_factory=list) + root_url: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class Picture: + vector_image: Optional[VectorImage] = field( + metadata=config(field_name="com.linkedin.common.VectorImage"), + default=None, + ) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class MiniProfile: + entity_urn: Optional[URN] = None + public_identifier: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + occupation: Optional[str] = None + memorialized: bool = False + object_urn: Optional[URN] = None + picture: Optional[Picture] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class MessagingMember: + entity_urn: Optional[URN] = None + mini_profile: Optional[MiniProfile] = None + alternate_name: Optional[str] = None + alternate_image: Optional[Picture] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class Paging: + count: int = 0 + start: int = 0 + links: list[Any] = field(default_factory=list) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class TextEntity: + urn: Optional[URN] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class AttributeType: + text_entity: Optional[TextEntity] = field( + metadata=config(field_name="com.linkedin.pemberly.text.Entity"), default=None + ) + + +@dataclass_json +@dataclass +class Attribute: + start: int = 0 + length: int = 0 + type_: Optional[AttributeType] = field(metadata=config(field_name="type"), default=None) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class AttributedBody: + text: str = "" + attributes: list[Attribute] = field(default_factory=list) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class MessageAttachmentCreate: + byte_size: int = 0 + id_: Optional[URN] = field(metadata=config(field_name="id"), default=None) + media_type: str = "" + name: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class MessageAttachmentReference: + string: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class MessageAttachment: + id_: Optional[URN] = field(metadata=config(field_name="id"), default=None) + byte_size: int = 0 + media_type: str = "" + name: str = "" + reference: Optional[MessageAttachmentReference] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class AudioMetadata: + urn: Optional[URN] + duration: int = 0 + url: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class MediaAttachment: + media_type: str = "" + audio_metadata: Optional[AudioMetadata] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class GifInfo: + original_height: int = 0 + original_width: int = 0 + url: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ThirdPartyMediaInfo: + previewgif: Optional[GifInfo] = None + nanogif: Optional[GifInfo] = None + gif: Optional[GifInfo] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ThirdPartyMedia: + media_type: str = "" + id_: str = field(metadata=config(field_name="id"), default="") + media: Optional[ThirdPartyMediaInfo] = None + title: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class LegalText: + static_legal_text: str = "" + custom_legal_text: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class SpInmailStandardSubContent: + action: str = "" + action_text: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class SpInmailSubContent: + standard: Optional[SpInmailStandardSubContent] = field( + metadata=config( + field_name="com.linkedin.voyager.messaging.event.message.spinmail.SpInmailStandardSubContent" # noqa: E501 + ), + default=None, + ) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class SpInmailContent: + status: str = "" + sp_inmail_type: str = "" + advertiser_label: str = "" + body: str = "" + legal_text: Optional[LegalText] = None + sub_content: Optional[SpInmailSubContent] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ConversationNameUpdateContent: + new_name: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class MessageCustomContent: + conversation_name_update_content: Optional[ConversationNameUpdateContent] = field( + metadata=config( + field_name="com.linkedin.voyager.messaging.event.message.ConversationNameUpdateContent" # noqa: E501 + ), + default=None, + ) + sp_inmail_content: Optional[SpInmailContent] = field( + metadata=config( + field_name="com.linkedin.voyager.messaging.event.message.spinmail.SpInmailContent" # noqa: E501 + ), + default=None, + ) + third_party_media: Optional[ThirdPartyMedia] = field( + metadata=config(field_name="com.linkedin.voyager.messaging.shared.ThirdPartyMedia"), + default=None, + ) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class CommentaryText: + text: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class Commentary: + text: Optional[CommentaryText] + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class NavigationContext: + tracking_action_type: str = "" + action_target: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ArticleComponent: + navigation_context: Optional[NavigationContext] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ImageAttributes: + vector_image: Optional[VectorImage] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class Image: + attributes: list[ImageAttributes] = field(default_factory=list) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ImageComponent: + images: list[Image] = field(default_factory=list) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class Document: + transcribed_document_url: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class DocumentComponent: + document: Optional[Document] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class StreamLocations: + url: str = "" + expires_at: int = -1 + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ProgressiveStreams: + width: int = -1 + height: int = -1 + size: int = -1 + media_type: str = "" + streaming_locations: list[StreamLocations] = field(default_factory=list) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class VideoPlayMetadata: + progressive_streams: list[ProgressiveStreams] = field(default_factory=list) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class VideoComponent: + video_play_metadata: Optional[VideoPlayMetadata] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ArticleContent: + image_component: Optional[ImageComponent] = field( + metadata=config(field_name="com.linkedin.voyager.feed.render.ImageComponent"), + default=None, + ) + video_component: Optional[VideoComponent] = field( + metadata=config(field_name="com.linkedin.voyager.feed.render.LinkedInVideoComponent"), + default=None, + ) + document_component: Optional[DocumentComponent] = field( + metadata=config(field_name="com.linkedin.voyager.feed.render.DocumentComponent"), + default=None, + ) + article_component: Optional[ArticleComponent] = field( + metadata=config(field_name="com.linkedin.voyager.feed.render.ArticleComponent"), + default=None, + ) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ActorName: + text: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class Actor: + name: Optional[ActorName] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class FeedUpdate: + actor: Optional[Actor] = None + commentary: Optional[Commentary] = None + content: Optional[ArticleContent] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class MessageEvent: + body: str = "" + feed_update: Optional[FeedUpdate] = None + message_body_render_format: str = "" + subject: Optional[str] = None + recalled_at: Optional[datetime] = None + last_edited_at: Optional[datetime] = None + attributed_body: Optional[AttributedBody] = None + attachments: list[MessageAttachment] = field(default_factory=list) + media_attachments: list[MediaAttachment] = field(default_factory=list) + custom_content: Optional[MessageCustomContent] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class EventContent: + message_event: Optional[MessageEvent] = field( + metadata=config(field_name="com.linkedin.voyager.messaging.event.MessageEvent"), + default=None, + ) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class From: + messaging_member: Optional[MessagingMember] = field( + metadata=config(field_name="com.linkedin.voyager.messaging.MessagingMember"), + default=None, + ) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ReactionSummary: + count: int = 0 + first_reacted_at: Optional[datetime] = None + emoji: str = "" + viewer_reacted: bool = False + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ConversationEvent: + created_at: Optional[datetime] = None + entity_urn: Optional[URN] = None + event_content: Optional[EventContent] = None + subtype: str = "" + from_: Optional[From] = field(metadata=config(field_name="from"), default=None) + previous_event_in_conversation: Optional[URN] = None + reaction_summaries: list[ReactionSummary] = field(default_factory=list) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class Participant: + messaging_member: Optional[MessagingMember] = field( + metadata=config(field_name="com.linkedin.voyager.messaging.MessagingMember"), + default=None, + ) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class Conversation: + group_chat: bool = False + total_event_count: int = 0 + unread_count: int = 0 + read: Optional[bool] = None + last_activity_at: Optional[datetime] = None + entity_urn: Optional[URN] = None + name: str = "" + muted: bool = False + events: list[ConversationEvent] = field(default_factory=list) + participants: list[Participant] = field(default_factory=list) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ConversationsResponse(DataClassJsonMixin): + elements: list[Conversation] = field(default_factory=list) + paging: Optional[Paging] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ConversationResponse(DataClassJsonMixin): + elements: list[ConversationEvent] = field(default_factory=list) + paging: Optional[Paging] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class MessageCreate(DataClassJsonMixin): + attributed_body: Optional[AttributedBody] = None + body: str = "" + attachments: list[MessageAttachmentCreate] = field(default_factory=list) + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class MessageCreatedInfo: + created_at: Optional[datetime] = None + event_urn: Optional[URN] = None + backend_event_urn: Optional[URN] = None + conversation_urn: Optional[URN] = None + backend_conversation_urn: Optional[URN] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class SendMessageResponse(DataClassJsonMixin): + value: Optional[MessageCreatedInfo] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class UserProfileResponse(DataClassJsonMixin): + plain_id: str = "" + mini_profile: Optional[MiniProfile] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class SeenReceipt: + event_urn: URN + seen_at: Optional[datetime] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class RealTimeEventStreamEvent(DataClassJsonMixin): + # Action real-time events (marking as read for example) + action: Optional[str] = None + conversation: Optional[Union[Conversation, URN]] = None + + # Message real-time events + previous_event_in_conversation: Optional[URN] = None + event: Optional[ConversationEvent] = None + + # Reaction real-time events + reaction_added: Optional[bool] = None + actor_mini_profile_urn: Optional[URN] = None + event_urn: Optional[URN] = None + reaction_summary: Optional[ReactionSummary] = None + + # Seen Receipt real-time events + from_entity: Optional[URN] = None + seen_receipt: Optional[SeenReceipt] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ReactorProfile: + first_name: str = "" + last_name: str = "" + entity_urn: Optional[URN] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class Reactor: + reactor_urn: Optional[URN] = None + reactor: Optional[ReactorProfile] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class ReactorsResponse(DataClassJsonMixin): + elements: list[Reactor] = field(default_factory=list) + paging: Optional[Paging] = None + + +@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) +@dataclass +class Error(DataClassJsonMixin, Exception): + status: int = -1 diff --git a/linkedin_messaging/exceptions.py b/linkedin_messaging/exceptions.py new file mode 100644 index 0000000..951ce1f --- /dev/null +++ b/linkedin_messaging/exceptions.py @@ -0,0 +1,2 @@ +class TooManyRequestsError(Exception): + pass diff --git a/linkedin_messaging/linkedin.py b/linkedin_messaging/linkedin.py new file mode 100644 index 0000000..54fd0f1 --- /dev/null +++ b/linkedin_messaging/linkedin.py @@ -0,0 +1,578 @@ +from typing import Any, AsyncGenerator, Awaitable, Callable, Optional, TypeVar, Union, cast +from collections import defaultdict +from datetime import datetime +import asyncio +import json +import logging + +from bs4 import BeautifulSoup +from dataclasses_json.api import DataClassJsonMixin +import aiohttp +import aiohttp.client_exceptions + +from .api_objects import ( + URN, + Conversation, + ConversationResponse, + ConversationsResponse, + Error, + MessageAttachmentCreate, + MessageCreate, + Picture, + ReactorsResponse, + RealTimeEventStreamEvent, + SendMessageResponse, + UserProfileResponse, +) +from .exceptions import TooManyRequestsError + +REQUEST_HEADERS = { + "user-agent": " ".join( + [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5)", + "AppleWebKit/537.36 (KHTML, like Gecko)", + "Chrome/83.0.4103.116 Safari/537.36", + ] + ), + "accept-language": "en-AU,en-GB;q=0.9,en-US;q=0.8,en;q=0.7", + "x-li-lang": "en_US", + "x-restli-protocol-version": "2.0.0", + "x-li-track": json.dumps( + { + "clientVersion": "1.13.8031", + "mpVersion": "1.13.8031", + "osName": "web", + "timezoneOffset": 0, + "timezone": "Etc/UTC", + "deviceFormFactor": "DESKTOP", + "mpName": "voyager-web", + } + ), +} + +LINKEDIN_BASE_URL = "https://www.linkedin.com" +LOGIN_URL = f"{LINKEDIN_BASE_URL}/checkpoint/lg/login-submit" +LOGOUT_URL = f"{LINKEDIN_BASE_URL}/uas/logout" +REALTIME_CONNECT_URL = f"{LINKEDIN_BASE_URL}/realtime/connect" +VERIFY_URL = f"{LINKEDIN_BASE_URL}/checkpoint/challenge/verify" +API_BASE_URL = f"{LINKEDIN_BASE_URL}/voyager/api" + +SEED_URL = f"{LINKEDIN_BASE_URL}/login" +""" +URL to seed all of the auth requests +""" + + +T = TypeVar("T", bound=DataClassJsonMixin) + + +async def try_from_json(deserialise_to: T, response: aiohttp.ClientResponse) -> T: + if response.status < 200 or 300 <= response.status: + try: + error = Error.from_json(await response.text()) + except Exception: + raise Exception( + f"Deserialising to {deserialise_to} failed because response " + f"was {response.status}. Details: {await response.text()}" + ) + raise error + + text = await response.text() + try: + return deserialise_to.from_json(text) + except (json.JSONDecodeError, ValueError) as e: + try: + error = Error.from_json(text) + except Exception: + raise Exception( + f"Deserialising to {deserialise_to} failed. Error: {e}. " f"Response: {text}." + ) + raise error + + +class ChallengeException(Exception): + pass + + +class LinkedInMessaging: + session: aiohttp.ClientSession + two_factor_payload: dict[str, Any] + event_listeners: defaultdict[ + str, + list[ + Union[ + Callable[[RealTimeEventStreamEvent], Awaitable[None]], + Callable[[asyncio.exceptions.TimeoutError], Awaitable[None]], + Callable[[Exception], Awaitable[None]], + ] + ], + ] + + def __init__(self): + self.session = aiohttp.ClientSession() + self.event_listeners = defaultdict(list) + + @staticmethod + def from_cookies(li_at: str, jsessionid: str) -> "LinkedInMessaging": + linkedin = LinkedInMessaging() + linkedin.session.cookie_jar.update_cookies({"li_at": li_at, "JSESSIONID": jsessionid}) + linkedin.session.headers["csrf-token"] = jsessionid + return linkedin + + async def close(self): + await self.session.close() + + async def _get(self, relative_url: str, **kwargs: Any) -> aiohttp.ClientResponse: + return await self.session.get(API_BASE_URL + relative_url, **kwargs) + + async def _post(self, relative_url: str, **kwargs: Any) -> aiohttp.ClientResponse: + return await self.session.post(API_BASE_URL + relative_url, **kwargs) + + # region Authentication + + @property + def has_auth_cookies(self) -> bool: + cookie_names = {c.key for c in self.session.cookie_jar} + return "li_at" in cookie_names and "JSESSIONID" in cookie_names + + async def logged_in(self) -> bool: + if not self.has_auth_cookies: + return False + try: + return bool(await self.get_user_profile()) + except Exception as e: + logging.exception(f"Failed getting the user profile: {e}") + return False + + async def login_manual(self, li_at: str, jsessionid: str, new_session: bool = True): + if new_session: + if self.session: + await self.session.close() + self.session = aiohttp.ClientSession() + self.session.cookie_jar.update_cookies({"li_at": li_at, "JSESSIONID": jsessionid}) + self.session.headers["csrf-token"] = jsessionid.strip('"') + + async def login(self, email: str, password: str, new_session: bool = True): + if new_session: + if self.session: + await self.session.close() + self.session = aiohttp.ClientSession() + + # Get the CSRF token. + async with self.session.get(SEED_URL) as seed_response: + if seed_response.status != 200: + raise Exception("Couldn't open the CSRF seed page") + + soup = BeautifulSoup(await seed_response.text(), "html.parser") + login_csrf_param = soup.find("input", {"name": "loginCsrfParam"})["value"] + + # Login with username and password + async with self.session.post( + LOGIN_URL, + data={ + "loginCsrfParam": login_csrf_param, + "session_key": email, + "session_password": password, + }, + ) as login_response: + # Check to see if the user was successfully logged in with just email and + # password. + if self.has_auth_cookies: + for c in self.session.cookie_jar: + if c.key == "JSESSIONID": + self.session.headers["csrf-token"] = c.value.strip('"') + return + + # 2FA is required. Throw an exception. + soup = BeautifulSoup(await login_response.text(), "html.parser") + + # TODO (#1) better detection of 2FA vs bad password + if soup.find("input", {"name": "challengeId"}): + self.two_factor_payload = { + k: soup.find("input", {"name": k})["value"] + for k in ( + "csrfToken", + "pageInstance", + "resendUrl", + "challengeId", + "displayTime", + "challengeSource", + "requestSubmissionId", + "challengeType", + "challengeData", + "challengeDetails", + "failureRedirectUri", + "flowTreeId", + ) + } + self.two_factor_payload["language"] = "en-US" + self.two_factor_payload["recognizedDevice"] = "on" + raise ChallengeException() + + # TODO (#1) can we scrape anything from the page? + raise Exception("Failed to log in.") + + async def enter_2fa(self, two_factor_code: str): + async with self.session.post( + VERIFY_URL, data={**self.two_factor_payload, "pin": two_factor_code} + ): + if self.has_auth_cookies: + for c in self.session.cookie_jar: + if c.key == "JSESSIONID": + self.session.headers["csrf-token"] = c.value.strip('"') + return + # TODO (#1) can we scrape anything from the page? + raise Exception("Failed to log in.") + + async def logout(self) -> bool: + csrf_token = self.session.headers.get("csrf-token") + if not csrf_token: + return True + response = await self.session.get( + LOGOUT_URL, + params={"csrfToken": csrf_token}, + allow_redirects=False, + ) + return response.status == 303 + + # endregion + + # region Conversations + + async def get_conversations( + self, + last_activity_before: Optional[datetime] = None, + ) -> ConversationsResponse: + """ + Fetch list of conversations the user is in. + + :param last_activity_before: :class:`datetime` of the last chat activity to + consider + """ + if last_activity_before is None: + last_activity_before = datetime.now() + + params = { + "keyVersion": "LEGACY_INBOX", + # For some reason, createdBefore is the key, even though that makes + # absolutely no sense whatsoever. + "createdBefore": int(last_activity_before.timestamp() * 1000), + } + + res = await self._get("/messaging/conversations", params=params) + return cast(ConversationsResponse, await try_from_json(ConversationsResponse, res)) + + async def get_all_conversations(self) -> AsyncGenerator[Conversation, None]: + """ + A generator of all of the user's conversations using paging. + """ + last_activity_before = datetime.now() + while True: + conversations_response = await self.get_conversations( + last_activity_before=last_activity_before + ) + for c in conversations_response.elements: + yield c + + # The page size is 20, by default, so if we get less than 20, we are at the + # end of the list so we should stop. + if len(conversations_response.elements) < 20: + break + + if last_activity_at := conversations_response.elements[-1].last_activity_at: + last_activity_before = last_activity_at + else: + break + + async def get_conversation( + self, + conversation_urn: URN, + created_before: Optional[datetime] = None, + ) -> ConversationResponse: + """ + Fetch the given conversation. + + :param conversation_urn_id: LinkedIn URN for a conversation + :param created_before: datetime of the last chat activity to consider + """ + if len(conversation_urn.id_parts) != 1: + raise TypeError(f"Invalid conversation URN {conversation_urn}.") + + if created_before is None: + created_before = datetime.now() + + params = { + "createdBefore": int(created_before.timestamp() * 1000), + } + + res = await self._get( + f"/messaging/conversations/{conversation_urn.id_parts[0]}/events", + params=params, + ) + return cast(ConversationResponse, await try_from_json(ConversationResponse, res)) + + async def mark_conversation_as_read(self, conversation_urn: URN) -> bool: + res = await self._post( + f"/messaging/conversations/{conversation_urn.id_parts[-1]}", + json={"patch": {"$set": {"read": True}}}, + ) + return res.status == 200 + + # endregion + + # region Messages + + async def upload_media( + self, + data: bytes, + filename: str, + media_type: str, + ) -> MessageAttachmentCreate: + upload_metadata_response = await self._post( + "/voyagerMediaUploadMetadata", + params={"action": "upload"}, + json={ + "mediaUploadType": "MESSAGING_PHOTO_ATTACHMENT", + "fileSize": len(data), + "filename": filename, + }, + ) + if upload_metadata_response.status != 200: + raise Exception("Failed to send upload metadata.") + + upload_metadata_response_json = (await upload_metadata_response.json()).get("value", {}) + upload_url = upload_metadata_response_json.get("singleUploadUrl") + if not upload_url: + raise Exception("No upload URL provided") + + upload_response = await self.session.put(upload_url, data=data) + if upload_response.status != 201: + # TODO (#2) is there any other data that we get? + raise Exception("Failed to upload file.") + + return MessageAttachmentCreate( + len(data), + URN(upload_metadata_response_json.get("urn")), + media_type, + filename, + ) + + async def send_message( + self, + conversation_urn_or_recipients: Union[URN, list[URN]], + message_create: MessageCreate, + ) -> SendMessageResponse: + params = {"action": "create"} + message_create_key = "com.linkedin.voyager.messaging.create.MessageCreate" + + message_event: dict[str, Any] = { + "eventCreate": {"value": {message_create_key: message_create.to_dict()}} + } + + if isinstance(conversation_urn_or_recipients, list): + message_event["recipients"] = [r.get_id() for r in conversation_urn_or_recipients] + message_event["subtype"] = "MEMBER_TO_MEMBER" + payload = { + "keyVersion": "LEGACY_INBOX", + "conversationCreate": message_event, + } + res = await self._post( + "/messaging/conversations", + params=params, + json=payload, + ) + else: + conversation_id = conversation_urn_or_recipients.get_id() + res = await self._post( + f"/messaging/conversations/{conversation_id}/events", + params=params, + json=message_event, + headers=REQUEST_HEADERS, + ) + + return cast(SendMessageResponse, await try_from_json(SendMessageResponse, res)) + + async def delete_message(self, conversation_urn: URN, message_urn: URN) -> bool: + res = await self._post( + "/messaging/conversations/{}/events/{}".format( + conversation_urn, message_urn.id_parts[-1] + ), + params={"action": "recall"}, + ) + return res.status == 204 + + async def download_linkedin_media(self, url: str) -> bytes: + async with self.session.get(url) as media_resp: + if not media_resp.ok: + raise Exception(f"Failed downloading media. Response code {media_resp.status}") + return await media_resp.content.read() + + # endregion + + # region Reactions + + async def add_emoji_reaction( + self, + conversation_urn: URN, + message_urn: URN, + emoji: str, + ) -> bool: + res = await self._post( + "/messaging/conversations/{}/events/{}".format( + conversation_urn, message_urn.id_parts[-1] + ), + params={"action": "reactWithEmoji"}, + json={"emoji": emoji}, + ) + return res.status == 204 + + async def remove_emoji_reaction( + self, + conversation_urn: URN, + message_urn: URN, + emoji: str, + ) -> bool: + res = await self._post( + "/messaging/conversations/{}/events/{}".format( + conversation_urn, message_urn.id_parts[-1] + ), + params={"action": "unreactWithEmoji"}, + json={"emoji": emoji}, + ) + return res.status == 204 + + async def get_reactors(self, message_urn: URN, emoji: str) -> ReactorsResponse: + params = { + "decorationId": "com.linkedin.voyager.dash.deco.messaging.FullReactor-8", + "emoji": emoji, + "messageUrn": f"urn:li:fsd_message:{message_urn.id_parts[-1]}", + "q": "messageAndEmoji", + } + res = await self._get("/voyagerMessagingDashReactors", params=params) + return cast(ReactorsResponse, await try_from_json(ReactorsResponse, res)) + + # endregion + + # region Typing Notifications + + async def set_typing(self, conversation_urn: URN): + await self._post( + "/messaging/conversations", + params={"action": "typing"}, + json={"conversationId": conversation_urn.get_id()}, + ) + + # endregion + + # region Profiles + + async def get_user_profile(self) -> UserProfileResponse: + res = await self._get("/me") + return cast(UserProfileResponse, await try_from_json(UserProfileResponse, res)) + + async def download_profile_picture(self, picture: Picture) -> bytes: + if not picture.vector_image: + raise Exception( + "Failed downloading media. Invalid Picture object with no vector_image." + ) + url = ( + picture.vector_image.root_url + + picture.vector_image.artifacts[-1].file_identifying_url_path_segment + ) + async with await self.session.get(url) as profile_resp: + if not profile_resp.ok: + raise Exception(f"Failed downloading media. Response code {profile_resp.status}") + return await profile_resp.content.read() + + # endregion + + # region Event Listener + + def add_event_listener( + self, + payload_key: str, + fn: Union[ + Callable[[RealTimeEventStreamEvent], Awaitable[None]], + Callable[[asyncio.exceptions.TimeoutError], Awaitable[None]], + Callable[[Exception], Awaitable[None]], + ], + ): + """ + There are two special event types: + + * ``ALL_EVENTS`` - an event fired on every event, and which contains the entirety of the + raw event payload + * ``TIMEOUT`` - an event fired if the event listener connection times out + """ + self.event_listeners[payload_key].append(fn) + + async def _fire(self, payload_key: str, event: Any): + for listener in self.event_listeners[payload_key]: + try: + await listener(event) + except Exception: + logging.exception(f"Listener {listener} failed to handle {event}") + + async def _listen_to_event_stream(self): + logging.info("Starting event stream listener") + + async with self.session.get( + REALTIME_CONNECT_URL, + headers={ + "accept": "text/event-stream", + "connection": "keep-alive", + "x-li-accept": "application/vnd.linkedin.normalized+json+2.1", + **REQUEST_HEADERS, + }, + # The event stream normally stays open for about 3 minutes, but this will + # automatically close it more agressively so that we don't get into a weird + # state where it's not receiving any data, but simultaneously isn't closed. + timeout=120, + ) as resp: + if resp.status != 200: + raise TooManyRequestsError(f"Failed to connect. Status {resp.status}.") + + while True: + line = await resp.content.readline() + if resp.content.at_eof(): + break + + if not line.startswith(b"data:"): + continue + data = json.loads(line.decode("utf-8")[6:]) + + # Special handling for ALL_EVENTS handler. + if all_events_handlers := self.event_listeners.get("ALL_EVENTS"): + for handler in all_events_handlers: + try: + await handler(data) + except Exception: + logging.exception(f"Handler {handler} failed to handle {data}") + + event_payload = data.get("com.linkedin.realtimefrontend.DecoratedEvent", {}).get( + "payload", {} + ) + + for key in self.event_listeners.keys(): + if event_payload.get(key) is not None: + await self._fire(key, RealTimeEventStreamEvent.from_dict(event_payload)) + + logging.info("Event stream closed") + + async def start_listener(self): + while True: + try: + await self._listen_to_event_stream() + except asyncio.exceptions.TimeoutError as te: + # Special handling for TIMEOUT handler. + if timeout_handlers := self.event_listeners.get("TIMEOUT"): + for handler in timeout_handlers: + try: + await handler(te) + except Exception: + logging.exception(f"Handler {handler} failed to handle {te}") + except Exception as e: + logging.exception(f"Got exception in listener: {e}") + raise + + # endregion diff --git a/linkedin_messaging/py.typed b/linkedin_messaging/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index 3db473a..c7f494f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ aiohttp>=3,<4 asyncpg>=0.20,<0.28 +beautifulsoup4>=4,<5 commonmark>=0.8,<0.10 -linkedin-messaging==0.6.0 +dataclasses-json>=0.6.3,<0.7 mautrix>=0.20.1,<0.21 pycryptodome>=3,<4 python-magic>=0.4,<0.5 diff --git a/tests/test_urn.py b/tests/test_urn.py new file mode 100644 index 0000000..82239cd --- /dev/null +++ b/tests/test_urn.py @@ -0,0 +1,13 @@ +from linkedin_messaging import URN + + +def test_urn_equivalence(): + assert URN("urn:123") == URN("123") + assert URN("urn:(123,456)") == URN("urn:test:(123,456)") + + +def test_urn_equivalence_in_tuple(): + assert (URN("urn:123"), URN("urn:(123,456)")) == ( + URN("123"), + URN("urn:test:(123,456)"), + )