55
66from __future__ import annotations
77
8+ import asyncio
9+ import weakref
810from collections .abc import AsyncIterable , Awaitable , Callable
911from dataclasses import dataclass , field
1012from datetime import datetime
@@ -387,6 +389,100 @@ class SerializedMessage(_SerializedMessageRequired, total=False):
387389 links : list [SerializedLinkPreview ]
388390
389391
392+ @dataclass
393+ class MessageSubjectParty :
394+ """A person referenced by a :class:`MessageSubject` (assignee/author).
395+
396+ Mirrors the inline ``{ id: string; name: string }`` shape used by
397+ upstream's ``MessageSubject.assignee`` / ``MessageSubject.author``.
398+ """
399+
400+ id : str
401+ name : str
402+
403+
404+ @dataclass
405+ class MessageSubject :
406+ """The external subject a message refers to (e.g. a Linear issue or GitHub PR).
407+
408+ Python port of the TS ``MessageSubject`` interface
409+ (``packages/chat/src/types.ts``). Resolved lazily via
410+ :attr:`Message.subject`, which delegates to the owning adapter's
411+ optional :meth:`Adapter.fetch_subject` hook.
412+
413+ Field names are snake_case per the Python port convention; ``raw`` is
414+ the platform-specific escape hatch.
415+ """
416+
417+ # ``id`` and ``type`` are the only required fields upstream; everything
418+ # else is optional. ``raw`` is required upstream but defaults to ``None``
419+ # here so partially-populated subjects (e.g. in tests) construct cleanly.
420+ id : str
421+ type : str
422+ raw : Any = None
423+ assignee : MessageSubjectParty | None = None
424+ author : MessageSubjectParty | None = None
425+ description : str | None = None
426+ labels : list [str ] | None = None
427+ status : str | None = None
428+ title : str | None = None
429+ url : str | None = None
430+
431+
432+ # --------------------------------------------------------------------------
433+ # Message -> Adapter registry (powers ``Message.subject``)
434+ # --------------------------------------------------------------------------
435+ #
436+ # Upstream (``packages/chat/src/message.ts``) uses
437+ # ``const adapterMap = new WeakMap<Message, Adapter>()`` so a dispatched
438+ # message can lazily ask its owning adapter to resolve its subject, without
439+ # the message holding a hard reference to the adapter and without leaking
440+ # messages after they fall out of scope.
441+ #
442+ # Python port hazard — hashability/weakref:
443+ # ``Message`` is a plain ``@dataclass`` (``eq=True``), which makes instances
444+ # *unhashable*. A ``weakref.WeakKeyDictionary[Message, Adapter]`` therefore
445+ # raises ``TypeError: unhashable type: 'Message'``. We deliberately do NOT
446+ # change ``Message`` to ``eq=False``/``frozen=True`` (that would alter its
447+ # public equality contract). Instead we key a plain ``dict`` by
448+ # ``id(message)`` (object identity, matching ``WeakMap`` semantics) and
449+ # register a ``weakref.finalize`` callback per message that pops the entry
450+ # when the message is garbage-collected. ``weakref.ref(message)`` works on a
451+ # plain dataclass even though ``hash()`` does not, so this is safe. The
452+ # finalizer also closes the ``id()`` reuse hole: the entry is removed before
453+ # CPython can recycle the id for a new object.
454+ _message_adapter_map : dict [int , Adapter ] = {}
455+
456+
457+ def set_message_adapter (message : Message , adapter : Adapter ) -> None :
458+ """Register the adapter that owns ``message`` (powers ``message.subject``).
459+
460+ Called by :class:`~chat_sdk.chat.Chat` at the dispatch bind site so every
461+ message handed to a handler can resolve its subject via the adapter's
462+ optional :meth:`Adapter.fetch_subject` hook.
463+
464+ Mirrors upstream ``setMessageAdapter`` (``packages/chat/src/message.ts``).
465+ The mapping is keyed by object identity and weakly scoped: when ``message``
466+ is garbage-collected, its entry is removed automatically.
467+ """
468+ key = id (message )
469+ _message_adapter_map [key ] = adapter
470+
471+ # Drop the entry when the message is GC'd. A zero-arg closure (rather than
472+ # ``weakref.finalize(message, dict.pop, key, None)``) captures ``key`` and
473+ # keeps the finalizer callable's type unambiguous for the type-checker.
474+ # ``pop(key, None)`` is a no-op if the entry was already removed.
475+ def _cleanup () -> None :
476+ _message_adapter_map .pop (key , None )
477+
478+ weakref .finalize (message , _cleanup )
479+
480+
481+ def _get_message_adapter (message : Message ) -> Adapter | None :
482+ """Return the adapter registered for ``message``, or ``None``."""
483+ return _message_adapter_map .get (id (message ))
484+
485+
390486def _strip_none (d : dict [str , Any ]) -> dict [str , Any ]:
391487 """Remove keys whose value is ``None`` from a dict.
392488
@@ -412,6 +508,63 @@ class Message:
412508 links : list [LinkPreview ] | None = None
413509 raw : Any = None
414510
511+ # Cached awaitable for ``subject``. Mirrors upstream's ``_subjectPromise``:
512+ # the first ``await message.subject`` stores the in-flight future here so a
513+ # second access reuses it instead of re-calling ``fetch_subject``.
514+ # ``init=False``/``compare=False``/``repr=False`` keep it out of the
515+ # dataclass ``__init__``, equality, and ``repr`` — it is purely internal
516+ # resolution state, not message data.
517+ _subject_future : Any = field (default = None , init = False , compare = False , repr = False )
518+
519+ async def _resolve_subject (self ) -> MessageSubject | None :
520+ """Resolve the subject via the owning adapter's ``fetch_subject`` hook.
521+
522+ Returns ``None`` when no adapter is registered, the adapter has no
523+ ``fetch_subject`` hook, the hook returns ``None``, or the hook raises
524+ (failures are swallowed, mirroring upstream's ``.catch(() => null)``).
525+ """
526+ adapter = _get_message_adapter (self )
527+ fetch_subject = getattr (adapter , "fetch_subject" , None )
528+ if adapter is None or fetch_subject is None :
529+ return None
530+ try :
531+ return await fetch_subject (self .raw )
532+ except Exception :
533+ return None
534+
535+ async def _subject (self ) -> MessageSubject | None :
536+ """Coroutine backing the :attr:`subject` accessor (caches the result).
537+
538+ The first await schedules ``_resolve_subject`` once via
539+ ``ensure_future`` and stores the shared future on the instance; every
540+ later/concurrent await reuses it, so ``fetch_subject`` runs at most
541+ once. Mirrors upstream's cached ``_subjectPromise``.
542+ """
543+ if self ._subject_future is None :
544+ self ._subject_future = asyncio .ensure_future (self ._resolve_subject ())
545+ return await self ._subject_future
546+
547+ @property
548+ def subject (self ) -> Awaitable [MessageSubject | None ]:
549+ """The external subject this message refers to (issue, PR, etc.), or ``None``.
550+
551+ Lazily resolved via the owning adapter's optional
552+ :meth:`Adapter.fetch_subject` hook. The adapter is registered at
553+ dispatch time by :func:`set_message_adapter`.
554+
555+ Mirrors upstream ``Message.subject`` (``packages/chat/src/message.ts``):
556+ it is an awaitable, the result is cached after the first access, and a
557+ second ``await message.subject`` does NOT re-call ``fetch_subject``.
558+ Concurrent awaits share a single in-flight resolution.
559+
560+ Usage::
561+
562+ subject = await message.subject
563+ if subject is not None:
564+ ...
565+ """
566+ return self ._subject ()
567+
415568 def to_json (self ) -> dict [str , Any ]:
416569 """Serialize to JSON-compatible dict.
417570
@@ -1257,6 +1410,17 @@ async def get_user(self, user_id: str) -> UserInfo | None:
12571410 """
12581411 return None
12591412
1413+ # NOTE: ``fetch_subject`` is intentionally NOT declared here. Upstream's
1414+ # ``Adapter.fetchSubject`` is an *optional* member (``fetchSubject?(...)``),
1415+ # and in this Python port the established convention for optional adapter
1416+ # hooks (``stream``, ``open_dm``, ``rehydrate_attachment``,
1417+ # ``get_channel_visibility``, ...) is to declare them on :class:`BaseAdapter`
1418+ # only — NOT on this structural ``Protocol`` — so that adapters which don't
1419+ # implement them still satisfy ``Adapter`` for type-checking. Declaring it
1420+ # on the Protocol would make it a *required* attribute and break every
1421+ # adapter that doesn't define it. :attr:`Message.subject` reads the hook via
1422+ # ``getattr(adapter, "fetch_subject", None)``, so presence is fully optional.
1423+
12601424
12611425class BaseAdapter :
12621426 """Base adapter with default implementations for optional methods.
@@ -1415,6 +1579,22 @@ async def get_user(self, user_id: str) -> UserInfo | None:
14151579 """
14161580 raise ChatNotImplementedError (self .name , "getUser" )
14171581
1582+ async def fetch_subject (self , raw : Any ) -> MessageSubject | None :
1583+ """Resolve the external subject a message refers to (issue, PR, etc.).
1584+
1585+ Optional — the default returns ``None`` (no subject). Adapters that
1586+ can resolve a backing entity (a Linear issue, a GitHub PR, etc.) from a
1587+ message's raw payload should override this. Unlike most optional
1588+ :class:`BaseAdapter` hooks it does *not* raise
1589+ :class:`~chat_sdk.errors.ChatNotImplementedError`, because
1590+ :attr:`Message.subject` is best-effort: "this adapter has no subject
1591+ concept" is a normal, non-error outcome that maps to ``None``.
1592+
1593+ Mirrors upstream's optional ``Adapter.fetchSubject``
1594+ (``packages/chat/src/types.ts``).
1595+ """
1596+ return None
1597+
14181598 def rehydrate_attachment (self , attachment : Attachment ) -> Attachment :
14191599 """Reconstruct ``fetch_data`` on an attachment after deserialization.
14201600
0 commit comments