|
22 | 22 | from chat_sdk.adapters.linear.cards import card_to_linear_markdown |
23 | 23 | from chat_sdk.adapters.linear.format_converter import LinearFormatConverter |
24 | 24 | from chat_sdk.adapters.linear.types import ( |
| 25 | + AgentActivityWebhookPayload, |
| 26 | + AgentSessionEventWebhookPayload, |
| 27 | + AgentSessionUserChild, |
| 28 | + AgentSessionWebhookPayload, |
25 | 29 | CommentWebhookPayload, |
| 30 | + LinearActorData, |
26 | 31 | LinearAdapterBaseConfig, |
27 | 32 | LinearAdapterConfig, |
28 | 33 | LinearAdapterMode, |
| 34 | + LinearAgentSessionCommentRawMessage, |
29 | 35 | LinearAgentSessionThreadId, |
30 | 36 | LinearCommentData, |
31 | 37 | LinearCommentRawMessage, |
|
88 | 94 | COMMENT_THREAD_PATTERN = re.compile(r"^([^:]+):c:([^:]+)$") |
89 | 95 | ISSUE_SESSION_THREAD_PATTERN = re.compile(r"^([^:]+):s:([^:]+)$") |
90 | 96 |
|
| 97 | +# Linear profile URL → display name. Faithful port of upstream |
| 98 | +# ``PROFILE_URL_REGEX`` (utils.ts:34). Anchored at the start (``^``) and stops |
| 99 | +# the captured slug at the first ``/``, ``?``, or ``#`` so query strings / |
| 100 | +# fragments / trailing path segments are excluded. |
| 101 | +PROFILE_URL_REGEX = re.compile(r"^https://linear\.app/\S+/profiles/([^/?#]+)") |
| 102 | + |
91 | 103 | # Linear GraphQL API endpoint |
92 | 104 | LINEAR_API_URL = "https://api.linear.app/graphql" |
93 | 105 |
|
|
113 | 125 | } |
114 | 126 |
|
115 | 127 |
|
| 128 | +def get_user_name_from_profile_url(url: str) -> str: |
| 129 | + """Extract a user display name from a Linear profile URL. |
| 130 | +
|
| 131 | + Faithful port of upstream ``getUserNameFromProfileUrl`` (utils.ts:40). A bit |
| 132 | + of a hack to avoid fetching the user just to get the display name: the slug |
| 133 | + after ``/profiles/`` in a Linear profile URL is the user's name. Returns |
| 134 | + ``""`` (NOT ``None``) when the URL does not match — upstream returns the |
| 135 | + empty string so the author's ``userName`` falls back to "" rather than |
| 136 | + propagating an undefined. |
| 137 | + """ |
| 138 | + match = PROFILE_URL_REGEX.match(url) |
| 139 | + if not match: |
| 140 | + return "" |
| 141 | + return match.group(1) |
| 142 | + |
| 143 | + |
116 | 144 | def assert_agent_session_thread( |
117 | 145 | thread: LinearThreadId, |
118 | 146 | ) -> LinearAgentSessionThreadId: |
@@ -566,10 +594,29 @@ async def handle_webhook( |
566 | 594 | # Handle events based on type. The payload shape is determined by |
567 | 595 | # `type` at runtime — cast to the matching TypedDict so each handler |
568 | 596 | # sees the right variant. |
| 597 | + # |
| 598 | + # Mode-gating (faithful port of index.ts:1144-1165): |
| 599 | + # - "Comment" events are only handled in mode="comments" |
| 600 | + # (``this.mode !== "comments" || action !== "create"`` → return). |
| 601 | + # - "AgentSessionEvent" events are only handled in |
| 602 | + # mode="agent-sessions"; in any other mode we warn and ignore. |
| 603 | + # The gates are mutually exclusive, so a comment event in |
| 604 | + # agent-sessions mode (and vice-versa) is dropped — even when the body |
| 605 | + # @-mentions the bot's userName. |
569 | 606 | payload_type = payload.get("type") |
570 | 607 | if payload_type == "Comment": |
571 | | - if payload.get("action") == "create": |
| 608 | + # Combined guard mirrors upstream's single `if` (mode + action). An |
| 609 | + # empty-string / wrong action and a non-"comments" mode both fall |
| 610 | + # through to the no-op without dispatching. |
| 611 | + if self._mode == "comments" and payload.get("action") == "create": |
572 | 612 | self._handle_comment_created(cast("CommentWebhookPayload", payload), options) |
| 613 | + elif payload_type == "AgentSessionEvent": |
| 614 | + if self._mode != "agent-sessions": |
| 615 | + self._logger.warn( |
| 616 | + "Received AgentSessionEvent webhook but adapter is not in agent-sessions mode, ignoring" |
| 617 | + ) |
| 618 | + else: |
| 619 | + self._handle_agent_session_event(cast("AgentSessionEventWebhookPayload", payload), options) |
573 | 620 | elif payload_type == "Reaction": |
574 | 621 | self._handle_reaction(cast("ReactionWebhookPayload", payload)) |
575 | 622 |
|
@@ -638,6 +685,286 @@ def _handle_comment_created( |
638 | 685 |
|
639 | 686 | self._chat.process_message(self, thread_id, message, options) |
640 | 687 |
|
| 688 | + def _parse_agent_session_message( |
| 689 | + self, |
| 690 | + raw: LinearAgentSessionCommentRawMessage, |
| 691 | + ) -> Message: |
| 692 | + """Build a ``Message`` from an agent-session raw message. |
| 693 | +
|
| 694 | + Faithful port of upstream ``parseMessage`` (index.ts:2026) for the |
| 695 | + ``agent_session_comment`` branch. The existing :meth:`parse_message` |
| 696 | + predates the upstream rewrite and does not reproduce the threadId |
| 697 | + encode / ``is_mention`` / structured-author behavior, so the |
| 698 | + agent-session path renders the ``Message`` here directly: |
| 699 | +
|
| 700 | + - ``is_mention=True`` — agent-session comments directly target the bot, |
| 701 | + so upstream always treats them as mentions. |
| 702 | + - ``thread_id`` is re-encoded from the raw comment so the session |
| 703 | + segment (``:s:{agentSessionId}``) is present on the routed thread. |
| 704 | + - ``author`` is read from the structured ``comment.user`` written by |
| 705 | + :meth:`_parse_message_from_agent_session_event` (display name, full |
| 706 | + name, ``is_bot`` from ``type == "bot"``, ``is_me`` from bot-user-id). |
| 707 | + """ |
| 708 | + comment = raw["comment"] |
| 709 | + text = cast("str", comment.get("body", "")) |
| 710 | + user: LinearActorData = cast("LinearActorData", comment.get("user", {})) |
| 711 | + |
| 712 | + thread_id = self.encode_thread_id( |
| 713 | + LinearThreadId( |
| 714 | + issue_id=cast("str", comment.get("issueId", "")), |
| 715 | + comment_id=cast("str | None", comment.get("id")), |
| 716 | + agent_session_id=raw["agentSessionId"], |
| 717 | + ) |
| 718 | + ) |
| 719 | + |
| 720 | + # createdAt / updatedAt are ISO strings (the "created" branch reads |
| 721 | + # `payload.createdAt` as a raw string — no Date cast). `edited` mirrors |
| 722 | + # upstream's `createdAt !== updatedAt`. |
| 723 | + created_at = cast("str", comment.get("createdAt", "")) |
| 724 | + updated_at = cast("str", comment.get("updatedAt", "")) |
| 725 | + |
| 726 | + author = Author( |
| 727 | + user_id=cast("str", user.get("id", "")), |
| 728 | + user_name=cast("str", user.get("displayName", "")), |
| 729 | + full_name=cast("str", user.get("fullName", "")), |
| 730 | + is_bot=user.get("type") == "bot", |
| 731 | + is_me=user.get("id") == self._bot_user_id, |
| 732 | + ) |
| 733 | + |
| 734 | + return Message( |
| 735 | + id=cast("str", comment.get("id", "")), |
| 736 | + thread_id=thread_id, |
| 737 | + is_mention=True, |
| 738 | + text=text, |
| 739 | + formatted=self._format_converter.to_ast(text), |
| 740 | + author=author, |
| 741 | + metadata=MessageMetadata( |
| 742 | + date_sent=_parse_iso(created_at) if created_at else datetime.now(timezone.utc), |
| 743 | + edited=created_at != updated_at, |
| 744 | + edited_at=_parse_iso(updated_at) if (created_at != updated_at and updated_at) else None, |
| 745 | + ), |
| 746 | + attachments=[], |
| 747 | + raw=cast("LinearRawMessage", raw), |
| 748 | + ) |
| 749 | + |
| 750 | + def _parse_message_from_agent_session_event( |
| 751 | + self, |
| 752 | + payload: AgentSessionEventWebhookPayload, |
| 753 | + ) -> Message | None: |
| 754 | + """Parse an agent-session webhook event into a chat message, if applicable. |
| 755 | +
|
| 756 | + Faithful port of upstream ``parseMessageFromAgentSessionEvent`` |
| 757 | + (index.ts:955). Returns ``None`` (and logs a warning) when the event |
| 758 | + cannot be parsed. Handles two actions: |
| 759 | +
|
| 760 | + - ``"prompted"`` — a user posting a follow-up in an existing session. |
| 761 | + - ``"created"`` — a user @-mentioning the bot, creating a new session. |
| 762 | +
|
| 763 | + Any other action logs an "Unsupported agent session event action" |
| 764 | + warning and returns ``None``. |
| 765 | + """ |
| 766 | + agent_session = cast("AgentSessionWebhookPayload", payload.get("agentSession", {})) |
| 767 | + |
| 768 | + # `issueId ?? issue?.id` — nullish (NOT truthy) fallback. Only fall back |
| 769 | + # to the nested issue.id when issueId is *absent*; an empty string would |
| 770 | + # be a real (if unusual) value, but we mirror upstream's `!issueId` |
| 771 | + # falsy guard below, which still bails on empty. |
| 772 | + issue_id = agent_session.get("issueId") |
| 773 | + if issue_id is None: |
| 774 | + issue = agent_session.get("issue") |
| 775 | + issue_id = issue.get("id") if issue is not None else None |
| 776 | + if not issue_id: |
| 777 | + return None |
| 778 | + |
| 779 | + action = payload.get("action") |
| 780 | + |
| 781 | + # |
| 782 | + # Follow-up message posted in an existing agent-session thread. |
| 783 | + # |
| 784 | + if action == "prompted": |
| 785 | + agent_activity = cast("AgentActivityWebhookPayload | None", payload.get("agentActivity")) |
| 786 | + if not agent_activity: |
| 787 | + self._logger.warn( |
| 788 | + "Missing agent activity for prompted action", |
| 789 | + {"agentSessionId": agent_session.get("id")}, |
| 790 | + ) |
| 791 | + return None |
| 792 | + |
| 793 | + source_comment_id = agent_activity.get("sourceCommentId") |
| 794 | + if not source_comment_id: |
| 795 | + self._logger.warn( |
| 796 | + "Missing source comment ID for agent activity", |
| 797 | + { |
| 798 | + "agentSessionId": agent_session.get("id"), |
| 799 | + "agentActivityId": agent_activity.get("id"), |
| 800 | + }, |
| 801 | + ) |
| 802 | + return None |
| 803 | + |
| 804 | + content = agent_activity.get("content", {}) |
| 805 | + activity_user = cast("AgentSessionUserChild", agent_activity.get("user", {})) |
| 806 | + # `agentActivity.user.avatarUrl ?? undefined` — nullish. |
| 807 | + avatar_url = activity_user.get("avatarUrl") |
| 808 | + # `parentId: payload.agentSession.comment?.id` — optional chain. |
| 809 | + # Short-circuit only on a missing/None comment (NOT a falsy empty |
| 810 | + # dict), mirroring `?.`; then read id (absent → None). |
| 811 | + prompted_session_comment = agent_session.get("comment") |
| 812 | + parent_id = prompted_session_comment.get("id") if prompted_session_comment is not None else None |
| 813 | + comment_data: LinearCommentData = { |
| 814 | + "id": cast("str", source_comment_id), |
| 815 | + "body": cast("str", content.get("body", "")), |
| 816 | + "issueId": cast("str", issue_id), |
| 817 | + "user": { |
| 818 | + "type": "user", |
| 819 | + "id": cast("str", activity_user.get("id", "")), |
| 820 | + "displayName": get_user_name_from_profile_url(cast("str", activity_user.get("url", ""))), |
| 821 | + "fullName": cast("str", activity_user.get("name", "")), |
| 822 | + "email": cast("str", activity_user.get("email")), |
| 823 | + **({"avatarUrl": cast("str", avatar_url)} if avatar_url is not None else {}), |
| 824 | + }, |
| 825 | + "parentId": cast("str", parent_id), |
| 826 | + "createdAt": cast("str", agent_activity.get("createdAt", "")), |
| 827 | + "updatedAt": cast("str", agent_activity.get("createdAt", "")), |
| 828 | + } |
| 829 | + # `payload.agentSession.url ?? undefined` — nullish. |
| 830 | + session_url = agent_session.get("url") |
| 831 | + if session_url is not None: |
| 832 | + comment_data["url"] = cast("str", session_url) |
| 833 | + |
| 834 | + # `payload.promptContext ?? undefined` — nullish. |
| 835 | + prompt_context = payload.get("promptContext") |
| 836 | + raw: LinearAgentSessionCommentRawMessage = { |
| 837 | + "kind": "agent_session_comment", |
| 838 | + "organizationId": cast("str", payload.get("organizationId", "")), |
| 839 | + "comment": comment_data, |
| 840 | + "agentSessionId": cast("str", agent_session.get("id", "")), |
| 841 | + } |
| 842 | + if prompt_context is not None: |
| 843 | + raw["agentSessionPromptContext"] = cast("str", prompt_context) |
| 844 | + return self._parse_agent_session_message(raw) |
| 845 | + |
| 846 | + # |
| 847 | + # New session: a user mentions the bot in an issue, opening a session |
| 848 | + # and posting the first message. |
| 849 | + # |
| 850 | + if action == "created": |
| 851 | + # App-ownership guard. `agentSession.appUserId !== this.botUserId`. |
| 852 | + # We deliberately compare on the raw (possibly-None) values so a |
| 853 | + # mismatch with a foreign bot's appUserId is rejected. A None |
| 854 | + # botUserId only "matches" when appUserId is *also* None — that |
| 855 | + # cannot happen for a real created event (appUserId is always set), |
| 856 | + # so we never falsely accept a foreign session. |
| 857 | + app_user_id = agent_session.get("appUserId") |
| 858 | + if app_user_id != self._bot_user_id: |
| 859 | + self._logger.warn( |
| 860 | + "Ignoring agent session event from another bot", |
| 861 | + { |
| 862 | + "agentSessionId": agent_session.get("id"), |
| 863 | + "appUserId": app_user_id, |
| 864 | + }, |
| 865 | + ) |
| 866 | + return None |
| 867 | + |
| 868 | + session_comment = agent_session.get("comment") |
| 869 | + if not session_comment: |
| 870 | + self._logger.warn( |
| 871 | + "Missing comment for agent session", |
| 872 | + {"agentSessionId": agent_session.get("id")}, |
| 873 | + ) |
| 874 | + return None |
| 875 | + |
| 876 | + creator = agent_session.get("creator") |
| 877 | + user: LinearActorData |
| 878 | + if creator: |
| 879 | + # `agentSession.creator.avatarUrl ?? undefined` — nullish. |
| 880 | + creator_avatar = creator.get("avatarUrl") |
| 881 | + user = { |
| 882 | + "type": "user", |
| 883 | + "id": cast("str", creator.get("id", "")), |
| 884 | + "displayName": get_user_name_from_profile_url(cast("str", creator.get("url", ""))), |
| 885 | + "fullName": cast("str", creator.get("name", "")), |
| 886 | + "email": cast("str", creator.get("email")), |
| 887 | + **({"avatarUrl": cast("str", creator_avatar)} if creator_avatar is not None else {}), |
| 888 | + } |
| 889 | + else: |
| 890 | + # No creator → fall back to the bot author (upstream uses |
| 891 | + # `this.botUserId` / `this.userName`). ``Author.user_id`` is a |
| 892 | + # non-Optional ``str``, so coerce a None bot-user-id (not yet |
| 893 | + # resolved by ``initialize``) to "" via ``is not None`` rather |
| 894 | + # than truthiness (CLAUDE.md hazard). |
| 895 | + user = { |
| 896 | + "type": "bot", |
| 897 | + "id": self._bot_user_id if self._bot_user_id is not None else "", |
| 898 | + "displayName": self._user_name, |
| 899 | + "fullName": self._user_name, |
| 900 | + } |
| 901 | + |
| 902 | + comment_data = { |
| 903 | + "id": cast("str", session_comment.get("id", "")), |
| 904 | + "body": cast("str", session_comment.get("body", "")), |
| 905 | + "issueId": cast("str", issue_id), |
| 906 | + "user": user, |
| 907 | + # The `created` branch reads `payload.createdAt` as a raw STRING |
| 908 | + # (no Date cast — upstream's `@ts-expect-error` notes the SDK |
| 909 | + # types are wrong about Date coercion for webhook payloads). |
| 910 | + "createdAt": cast("str", payload.get("createdAt", "")), |
| 911 | + "updatedAt": cast("str", payload.get("createdAt", "")), |
| 912 | + } |
| 913 | + # `payload.agentSession.url ?? undefined` — nullish. |
| 914 | + session_url = agent_session.get("url") |
| 915 | + if session_url is not None: |
| 916 | + comment_data["url"] = cast("str", session_url) |
| 917 | + |
| 918 | + # `payload.promptContext ?? undefined` — nullish. |
| 919 | + prompt_context = payload.get("promptContext") |
| 920 | + raw = { |
| 921 | + "kind": "agent_session_comment", |
| 922 | + "organizationId": cast("str", payload.get("organizationId", "")), |
| 923 | + "comment": comment_data, |
| 924 | + "agentSessionId": cast("str", agent_session.get("id", "")), |
| 925 | + } |
| 926 | + if prompt_context is not None: |
| 927 | + raw["agentSessionPromptContext"] = cast("str", prompt_context) |
| 928 | + return self._parse_agent_session_message(raw) |
| 929 | + |
| 930 | + self._logger.warn( |
| 931 | + "Unsupported agent session event action", |
| 932 | + { |
| 933 | + "action": action, |
| 934 | + "agentSessionId": agent_session.get("id"), |
| 935 | + "issueId": issue_id, |
| 936 | + }, |
| 937 | + ) |
| 938 | + return None |
| 939 | + |
| 940 | + def _handle_agent_session_event( |
| 941 | + self, |
| 942 | + payload: AgentSessionEventWebhookPayload, |
| 943 | + options: WebhookOptions | None = None, |
| 944 | + ) -> None: |
| 945 | + """Handle an agent-session webhook event. |
| 946 | +
|
| 947 | + Faithful port of upstream ``handleAgentSessionEvent`` (index.ts:1269). |
| 948 | + Builds a message via :meth:`_parse_message_from_agent_session_event` |
| 949 | + and routes it to ``chat.process_message``. There is NO automatic |
| 950 | + acknowledgement — the bot does not auto-respond on receipt (no |
| 951 | + agentActivityCreate / typing / stream side-effect here; those land in |
| 952 | + L4). When the event cannot be parsed, logs a warning and returns. |
| 953 | + """ |
| 954 | + if not self._chat: |
| 955 | + self._logger.warn("Chat instance not initialized, ignoring agent session event") |
| 956 | + return |
| 957 | + |
| 958 | + message = self._parse_message_from_agent_session_event(payload) |
| 959 | + if not message: |
| 960 | + self._logger.warn( |
| 961 | + "Unable to build message for Linear agent session event", |
| 962 | + {"agentSessionId": payload.get("agentSession", {}).get("id")}, |
| 963 | + ) |
| 964 | + return |
| 965 | + |
| 966 | + self._chat.process_message(self, message.thread_id, message, options) |
| 967 | + |
641 | 968 | def _handle_reaction(self, payload: ReactionWebhookPayload) -> None: |
642 | 969 | """Handle reaction events (logging only).""" |
643 | 970 | if not self._chat: |
|
0 commit comments