skcomms 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. skcomms/__init__.py +58 -0
  2. skcomms/ack.py +296 -0
  3. skcomms/adapters/__init__.py +91 -0
  4. skcomms/adapters/base.py +230 -0
  5. skcomms/adapters/discord.py +808 -0
  6. skcomms/adapters/factory.py +145 -0
  7. skcomms/adapters/fake.py +119 -0
  8. skcomms/adapters/matrix.py +816 -0
  9. skcomms/adapters/models.py +127 -0
  10. skcomms/adapters/registry.py +277 -0
  11. skcomms/adapters/slack.py +549 -0
  12. skcomms/adapters/telegram.py +882 -0
  13. skcomms/api.py +1983 -0
  14. skcomms/capauth_validator.py +447 -0
  15. skcomms/cli.py +2554 -0
  16. skcomms/cluster.py +67 -0
  17. skcomms/compression.py +233 -0
  18. skcomms/config.py +156 -0
  19. skcomms/core.py +768 -0
  20. skcomms/crypto.py +368 -0
  21. skcomms/did_router.py +325 -0
  22. skcomms/discovery.py +535 -0
  23. skcomms/envelope.py +125 -0
  24. skcomms/glossa/__init__.py +9 -0
  25. skcomms/glossa/codebook.py +40 -0
  26. skcomms/glossa/codec.py +71 -0
  27. skcomms/glossa/emergent.py +90 -0
  28. skcomms/glossa/gloss.py +44 -0
  29. skcomms/glossa/handshake.py +45 -0
  30. skcomms/glossa/macros.py +83 -0
  31. skcomms/glossa/message.py +21 -0
  32. skcomms/glossa/session.py +84 -0
  33. skcomms/grants.py +455 -0
  34. skcomms/heartbeat.py +735 -0
  35. skcomms/home.py +138 -0
  36. skcomms/household_router.py +234 -0
  37. skcomms/identity.py +72 -0
  38. skcomms/integration.py +187 -0
  39. skcomms/key_exchange.py +541 -0
  40. skcomms/mailbox.py +274 -0
  41. skcomms/marketplace.py +359 -0
  42. skcomms/mcp_server.py +506 -0
  43. skcomms/metrics.py +243 -0
  44. skcomms/models.py +235 -0
  45. skcomms/outbox.py +420 -0
  46. skcomms/pairing.py +227 -0
  47. skcomms/peers.py +234 -0
  48. skcomms/profile_router.py +557 -0
  49. skcomms/pubsub.py +439 -0
  50. skcomms/pubsub_transport.py +294 -0
  51. skcomms/queue.py +334 -0
  52. skcomms/ratelimit.py +242 -0
  53. skcomms/realm.py +78 -0
  54. skcomms/registry.py +576 -0
  55. skcomms/router.py +553 -0
  56. skcomms/signaling.py +465 -0
  57. skcomms/signing.py +309 -0
  58. skcomms/souls_router.py +622 -0
  59. skcomms/tofu.py +219 -0
  60. skcomms/transport.py +144 -0
  61. skcomms/transports/__init__.py +6 -0
  62. skcomms/transports/audio_track.py +240 -0
  63. skcomms/transports/ble/__init__.py +8 -0
  64. skcomms/transports/ble/gatt.py +16 -0
  65. skcomms/transports/ble/identity.py +85 -0
  66. skcomms/transports/ble/node.py +80 -0
  67. skcomms/transports/ble/noise.py +96 -0
  68. skcomms/transports/ble/protocol.py +200 -0
  69. skcomms/transports/ble/radio.py +88 -0
  70. skcomms/transports/ble/relay.py +68 -0
  71. skcomms/transports/broker_server.py +50 -0
  72. skcomms/transports/file.py +575 -0
  73. skcomms/transports/lora/__init__.py +9 -0
  74. skcomms/transports/lora/addressing.py +52 -0
  75. skcomms/transports/lora/framing.py +32 -0
  76. skcomms/transports/lora/interface.py +90 -0
  77. skcomms/transports/lora/meshtastic_iface.py +61 -0
  78. skcomms/transports/lora/store.py +88 -0
  79. skcomms/transports/lora/transport.py +147 -0
  80. skcomms/transports/nostr.py +822 -0
  81. skcomms/transports/p2p_connector.py +101 -0
  82. skcomms/transports/p2p_manager.py +127 -0
  83. skcomms/transports/p2p_session.py +162 -0
  84. skcomms/transports/signaling_base.py +52 -0
  85. skcomms/transports/signaling_broker.py +111 -0
  86. skcomms/transports/signaling_mailbox.py +118 -0
  87. skcomms/transports/syncthing.py +513 -0
  88. skcomms/transports/tailscale.py +633 -0
  89. skcomms/transports/video_track.py +235 -0
  90. skcomms/transports/webrtc.py +1097 -0
  91. skcomms/transports/webrtc_media.py +716 -0
  92. skcomms/transports/websocket.py +507 -0
  93. skcomms-0.1.3.dist-info/METADATA +327 -0
  94. skcomms-0.1.3.dist-info/RECORD +98 -0
  95. skcomms-0.1.3.dist-info/WHEEL +5 -0
  96. skcomms-0.1.3.dist-info/entry_points.txt +3 -0
  97. skcomms-0.1.3.dist-info/licenses/LICENSE +674 -0
  98. skcomms-0.1.3.dist-info/top_level.txt +1 -0
skcomms/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """skcomms — sovereign communications for AI agents.
2
+
3
+ Multi-channel transport (Syncthing/file/websocket/WebRTC/Nostr/Tailscale/...)
4
+ unified under FQID ``<agent>@<operator>.<realm>`` sovereign addressing.
5
+
6
+ Transport layer (from skcomms, now canonical here):
7
+ from skcomms import SKComms
8
+ from skcomms.core import SKComms
9
+ from skcomms.models import MessageEnvelope
10
+
11
+ FQID / realm layer (pre-alpha stubs, implementations landing in coord tasks):
12
+ from skcomms.envelope import ...
13
+ from skcomms.realm import ...
14
+ from skcomms.identity import ...
15
+ from skcomms.cluster import ...
16
+ """
17
+
18
+ __version__ = "0.1.3"
19
+
20
+ from .core import SKComms, SKComm
21
+ from .crypto import EnvelopeCrypto, KeyStore
22
+ from .models import (
23
+ MessageEnvelope,
24
+ MessageMetadata,
25
+ MessagePayload,
26
+ MessageType,
27
+ RoutingConfig,
28
+ RoutingMode,
29
+ )
30
+ from .envelope import Envelope, SignedEnvelope
31
+ from .signing import EnvelopeSigner, EnvelopeVerifier, VerificationResult
32
+ from .transport import HealthStatus, SendResult, Transport, TransportError, TransportStatus
33
+
34
+ __all__ = [
35
+ # Transport layer
36
+ "SKComms",
37
+ "SKComm", # deprecated alias
38
+ "MessageEnvelope",
39
+ "MessageMetadata",
40
+ "MessagePayload",
41
+ "MessageType",
42
+ "RoutingConfig",
43
+ "RoutingMode",
44
+ "Transport",
45
+ "TransportError",
46
+ "TransportStatus",
47
+ "HealthStatus",
48
+ "SendResult",
49
+ "EnvelopeCrypto",
50
+ "KeyStore",
51
+ "Envelope",
52
+ "SignedEnvelope",
53
+ "EnvelopeSigner",
54
+ "EnvelopeVerifier",
55
+ "VerificationResult",
56
+ # FQID / realm layer (stubs — implementations pending)
57
+ # envelope, realm, identity, cluster importable directly
58
+ ]
skcomms/ack.py ADDED
@@ -0,0 +1,296 @@
1
+ """
2
+ SKComms delivery acknowledgment tracker.
3
+
4
+ When a message is sent with ack_requested=True, the sender
5
+ records it as "pending ACK." When the receiver processes the
6
+ message, it automatically sends an ACK envelope back. When
7
+ the original sender receives the ACK, the pending entry is
8
+ resolved as "confirmed."
9
+
10
+ Pending ACKs that exceed the timeout are marked "timed_out"
11
+ and can be retried or escalated.
12
+
13
+ Persistence: pending ACKs are stored as JSON in ~/.skcomms/acks/
14
+ so they survive process restarts.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from datetime import datetime, timezone
21
+ from enum import Enum
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ from pydantic import BaseModel, Field
26
+
27
+ from .config import SKCOMMS_HOME
28
+ from .models import MessageEnvelope
29
+
30
+ logger = logging.getLogger("skcomms.ack")
31
+
32
+ ACKS_DIR_NAME = "acks"
33
+ ACK_SUFFIX = ".ack.json"
34
+ DEFAULT_ACK_TIMEOUT = 300
35
+
36
+
37
+ class AckStatus(str, Enum):
38
+ """State of a pending acknowledgment."""
39
+
40
+ PENDING = "pending"
41
+ CONFIRMED = "confirmed"
42
+ TIMED_OUT = "timed_out"
43
+
44
+
45
+ class PendingAck(BaseModel):
46
+ """A tracked outbound message awaiting acknowledgment.
47
+
48
+ Attributes:
49
+ envelope_id: ID of the sent message.
50
+ recipient: Who the message was sent to.
51
+ sent_at: When the message was sent.
52
+ ack_timeout: Seconds to wait for an ACK.
53
+ status: Current ACK state.
54
+ confirmed_at: When the ACK was received.
55
+ confirmed_via: Transport that delivered the ACK.
56
+ """
57
+
58
+ envelope_id: str
59
+ recipient: str
60
+ sent_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
61
+ ack_timeout: int = DEFAULT_ACK_TIMEOUT
62
+ status: AckStatus = AckStatus.PENDING
63
+ confirmed_at: Optional[datetime] = None
64
+ confirmed_via: Optional[str] = None
65
+
66
+ @property
67
+ def is_expired(self) -> bool:
68
+ """Check if this ACK has timed out."""
69
+ if self.status != AckStatus.PENDING:
70
+ return False
71
+ age = (datetime.now(timezone.utc) - self.sent_at).total_seconds()
72
+ return age > self.ack_timeout
73
+
74
+
75
+ class AckTracker:
76
+ """Tracks outbound messages awaiting delivery acknowledgment.
77
+
78
+ Persists pending ACKs as JSON files. Resolves them when
79
+ matching ACK envelopes are received. Detects timeouts.
80
+
81
+ Args:
82
+ acks_dir: Directory for ACK tracking files.
83
+ default_timeout: Default seconds to wait for ACK.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ acks_dir: Optional[Path] = None,
89
+ default_timeout: int = DEFAULT_ACK_TIMEOUT,
90
+ ):
91
+ self._dir = acks_dir or Path(SKCOMMS_HOME).expanduser() / ACKS_DIR_NAME
92
+ self._dir.mkdir(parents=True, exist_ok=True)
93
+ self._default_timeout = default_timeout
94
+
95
+ @property
96
+ def acks_dir(self) -> Path:
97
+ """Path to the ACK tracking directory."""
98
+ return self._dir
99
+
100
+ def track(self, envelope: MessageEnvelope) -> Optional[PendingAck]:
101
+ """Begin tracking an outbound message for ACK.
102
+
103
+ Only tracks if ack_requested is True on the envelope.
104
+
105
+ Args:
106
+ envelope: The sent message envelope.
107
+
108
+ Returns:
109
+ PendingAck if tracking started, None if ACK not requested.
110
+ """
111
+ if not envelope.routing.ack_requested:
112
+ return None
113
+ if envelope.is_ack:
114
+ return None
115
+
116
+ pending = PendingAck(
117
+ envelope_id=envelope.envelope_id,
118
+ recipient=envelope.recipient,
119
+ ack_timeout=self._default_timeout,
120
+ )
121
+
122
+ path = self._dir / f"{envelope.envelope_id}{ACK_SUFFIX}"
123
+ path.write_text(pending.model_dump_json(indent=2))
124
+ logger.debug("Tracking ACK for %s -> %s", envelope.envelope_id[:8], envelope.recipient)
125
+ return pending
126
+
127
+ def process_ack(self, ack_envelope: MessageEnvelope) -> Optional[PendingAck]:
128
+ """Process a received ACK envelope and resolve the pending entry.
129
+
130
+ The ACK's content holds the original envelope_id.
131
+
132
+ Args:
133
+ ack_envelope: A received ACK-type envelope.
134
+
135
+ Returns:
136
+ The resolved PendingAck, or None if no matching pending found.
137
+ """
138
+ if not ack_envelope.is_ack:
139
+ return None
140
+
141
+ original_id = ack_envelope.payload.content
142
+ path = self._dir / f"{original_id}{ACK_SUFFIX}"
143
+
144
+ if not path.exists():
145
+ logger.debug("ACK for unknown envelope %s — ignoring", original_id[:8])
146
+ return None
147
+
148
+ try:
149
+ pending = PendingAck.model_validate_json(path.read_text())
150
+ except Exception as exc:
151
+ logger.warning("Failed to read pending ACK %s: %s", original_id[:8], exc)
152
+ return None
153
+
154
+ pending.status = AckStatus.CONFIRMED
155
+ pending.confirmed_at = datetime.now(timezone.utc)
156
+ pending.confirmed_via = ack_envelope.metadata.delivered_via
157
+
158
+ path.write_text(pending.model_dump_json(indent=2))
159
+ logger.info("ACK confirmed for %s from %s", original_id[:8], ack_envelope.sender)
160
+ return pending
161
+
162
+ def get(self, envelope_id: str) -> Optional[PendingAck]:
163
+ """Look up a pending ACK by envelope ID.
164
+
165
+ Args:
166
+ envelope_id: The original message's envelope ID.
167
+
168
+ Returns:
169
+ PendingAck or None if not tracked.
170
+ """
171
+ path = self._dir / f"{envelope_id}{ACK_SUFFIX}"
172
+ if not path.exists():
173
+ return None
174
+ try:
175
+ return PendingAck.model_validate_json(path.read_text())
176
+ except Exception as exc:
177
+ logger.debug("Failed to load ACK entry %s: %s", envelope_id[:8], exc)
178
+ return None
179
+
180
+ def list_pending(self) -> list[PendingAck]:
181
+ """List all ACKs still awaiting confirmation.
182
+
183
+ Returns:
184
+ List of PendingAck with PENDING status.
185
+ """
186
+ return [a for a in self._load_all() if a.status == AckStatus.PENDING]
187
+
188
+ def list_timed_out(self) -> list[PendingAck]:
189
+ """List pending ACKs that have exceeded their timeout.
190
+
191
+ Returns:
192
+ List of PendingAck that should have been confirmed by now.
193
+ """
194
+ return [a for a in self._load_all() if a.status == AckStatus.PENDING and a.is_expired]
195
+
196
+ def list_confirmed(self) -> list[PendingAck]:
197
+ """List all confirmed ACKs.
198
+
199
+ Returns:
200
+ List of PendingAck with CONFIRMED status.
201
+ """
202
+ return [a for a in self._load_all() if a.status == AckStatus.CONFIRMED]
203
+
204
+ def check_timeouts(self) -> list[PendingAck]:
205
+ """Mark expired pending ACKs as timed_out and return them.
206
+
207
+ Returns:
208
+ List of ACKs that just timed out.
209
+ """
210
+ newly_timed_out: list[PendingAck] = []
211
+ for pending in self._load_all():
212
+ if pending.status == AckStatus.PENDING and pending.is_expired:
213
+ pending.status = AckStatus.TIMED_OUT
214
+ path = self._dir / f"{pending.envelope_id}{ACK_SUFFIX}"
215
+ path.write_text(pending.model_dump_json(indent=2))
216
+ newly_timed_out.append(pending)
217
+ logger.warning("ACK timed out for %s", pending.envelope_id[:8])
218
+ return newly_timed_out
219
+
220
+ def purge_confirmed(self, max_age: int = 86400) -> int:
221
+ """Remove confirmed ACKs older than max_age seconds.
222
+
223
+ Args:
224
+ max_age: Maximum age in seconds for confirmed ACKs.
225
+
226
+ Returns:
227
+ Number of ACK files removed.
228
+ """
229
+ removed = 0
230
+ now = datetime.now(timezone.utc)
231
+ for pending in self._load_all():
232
+ if pending.status == AckStatus.CONFIRMED and pending.confirmed_at:
233
+ age = (now - pending.confirmed_at).total_seconds()
234
+ if age > max_age:
235
+ path = self._dir / f"{pending.envelope_id}{ACK_SUFFIX}"
236
+ if path.exists():
237
+ path.unlink()
238
+ removed += 1
239
+ return removed
240
+
241
+ def remove(self, envelope_id: str) -> bool:
242
+ """Remove an ACK tracking entry.
243
+
244
+ Args:
245
+ envelope_id: The original message's envelope ID.
246
+
247
+ Returns:
248
+ True if the entry was found and removed.
249
+ """
250
+ path = self._dir / f"{envelope_id}{ACK_SUFFIX}"
251
+ if path.exists():
252
+ path.unlink()
253
+ return True
254
+ return False
255
+
256
+ @property
257
+ def pending_count(self) -> int:
258
+ """Number of ACKs still pending."""
259
+ return len(self.list_pending())
260
+
261
+ def _load_all(self) -> list[PendingAck]:
262
+ """Load all ACK tracking files."""
263
+ results: list[PendingAck] = []
264
+ for path in sorted(self._dir.glob(f"*{ACK_SUFFIX}")):
265
+ try:
266
+ results.append(PendingAck.model_validate_json(path.read_text()))
267
+ except Exception as exc:
268
+ logger.warning("Skipping corrupt ACK file %s: %s", path.name, exc)
269
+ return results
270
+
271
+
272
+ def should_ack(envelope: MessageEnvelope) -> bool:
273
+ """Check if a received envelope requests an ACK.
274
+
275
+ Args:
276
+ envelope: The received envelope.
277
+
278
+ Returns:
279
+ True if the envelope requests acknowledgment and is not itself an ACK.
280
+ """
281
+ return envelope.routing.ack_requested and not envelope.is_ack
282
+
283
+
284
+ def make_ack_envelope(envelope: MessageEnvelope, sender: str) -> MessageEnvelope:
285
+ """Create an ACK envelope for a received message.
286
+
287
+ Convenience wrapper around MessageEnvelope.make_ack().
288
+
289
+ Args:
290
+ envelope: The received message to acknowledge.
291
+ sender: Our agent name (ACK sender).
292
+
293
+ Returns:
294
+ ACK envelope ready to send.
295
+ """
296
+ return envelope.make_ack(sender)
@@ -0,0 +1,91 @@
1
+ """
2
+ skcomms channel adapters (Batch C1 / C2 / C3 / C5).
3
+
4
+ Platform bridges translate between a foreign platform's wire format and the
5
+ normalized :class:`~skcomms.adapters.models.ChannelMessage`. The
6
+ :class:`~skcomms.adapters.registry.AdapterRegistry` manages the set of live
7
+ adapters and enforces the P0 unified-memory contract.
8
+
9
+ Public surface::
10
+
11
+ from skcomms.adapters import (
12
+ ChannelAdapter,
13
+ ChannelMessage,
14
+ PlatformIdentity,
15
+ AdapterCapabilities,
16
+ AdapterHealth,
17
+ AdapterRegistry,
18
+ TelegramAdapter,
19
+ SlackAdapter,
20
+ DiscordAdapter,
21
+ MatrixAdapter,
22
+ ChannelType,
23
+ MessageKind,
24
+ TrustLevel,
25
+ )
26
+
27
+ Spec: docs/superpowers/specs/2026-06-13-skcomms-channel-adapter.md
28
+ """
29
+
30
+ from .base import (
31
+ AdapterAuthError,
32
+ AdapterCapabilities,
33
+ AdapterConnectError,
34
+ AdapterError,
35
+ AdapterHealth,
36
+ AdapterSendError,
37
+ ChannelAdapter,
38
+ )
39
+ from .discord import DiscordAdapter
40
+ from .factory import (
41
+ BUILTIN_ADAPTERS,
42
+ build_adapter,
43
+ build_registry_from_config,
44
+ expand_env,
45
+ )
46
+ from .fake import FakeAdapter
47
+ from .matrix import MatrixAdapter
48
+ from .models import (
49
+ ChannelMessage,
50
+ ChannelType,
51
+ MediaAttachment,
52
+ MessageKind,
53
+ PlatformIdentity,
54
+ ResolvedIdentity,
55
+ TrustLevel,
56
+ )
57
+ from .registry import AdapterRegistry
58
+ from .slack import SlackAdapter
59
+ from .telegram import TelegramAdapter
60
+
61
+ __all__ = [
62
+ # ABC + health / capability models
63
+ "ChannelAdapter",
64
+ "AdapterCapabilities",
65
+ "AdapterHealth",
66
+ "AdapterError",
67
+ "AdapterAuthError",
68
+ "AdapterConnectError",
69
+ "AdapterSendError",
70
+ # Normalized message model
71
+ "ChannelMessage",
72
+ "ChannelType",
73
+ "MessageKind",
74
+ "PlatformIdentity",
75
+ "ResolvedIdentity",
76
+ "MediaAttachment",
77
+ "TrustLevel",
78
+ # Registry
79
+ "AdapterRegistry",
80
+ # Adapter implementations
81
+ "TelegramAdapter",
82
+ "SlackAdapter",
83
+ "DiscordAdapter",
84
+ "MatrixAdapter",
85
+ "FakeAdapter",
86
+ # Factory / config→registry plumbing
87
+ "BUILTIN_ADAPTERS",
88
+ "build_adapter",
89
+ "build_registry_from_config",
90
+ "expand_env",
91
+ ]
@@ -0,0 +1,230 @@
1
+ """
2
+ ChannelAdapter abstract base class (Batch C1).
3
+
4
+ Every platform bridge (Telegram, Slack, Discord, NC Talk, Matrix, …) must
5
+ implement this ABC. The adapter owns the platform edge; the skcomms hub owns
6
+ the interior (identity resolution, memory write, advocacy dispatch).
7
+
8
+ Spec: docs/superpowers/specs/2026-06-13-skcomms-channel-adapter.md §4
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass
15
+ from typing import AsyncIterator, Optional
16
+
17
+ from .models import ChannelMessage, ChannelType, PlatformIdentity
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Capability / health models
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ @dataclass
25
+ class AdapterCapabilities:
26
+ """
27
+ Declare what this adapter can do.
28
+
29
+ The hub uses these flags to decide whether to downgrade a rich
30
+ outbound message before forwarding (e.g. strip a voice note to a
31
+ transcript when voice_notes=False).
32
+ """
33
+
34
+ text: bool = True
35
+ files: bool = True
36
+ images: bool = True
37
+ voice_notes: bool = False
38
+ video: bool = False
39
+ reactions: bool = False
40
+ threads: bool = False # inline threading (Slack threads, TG reply-chain)
41
+ read_receipts: bool = False
42
+ typing_hint: bool = False
43
+ max_text_bytes: int = 4096 # platform message size limit
44
+
45
+
46
+ @dataclass
47
+ class AdapterHealth:
48
+ """Point-in-time health snapshot for monitoring."""
49
+
50
+ adapter_name: str
51
+ connected: bool
52
+ latency_ms: Optional[float]
53
+ error: Optional[str] = None
54
+ queued_outbound: int = 0 # messages waiting to be delivered
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Custom exceptions
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ class AdapterError(Exception):
63
+ """Base class for all adapter errors."""
64
+
65
+
66
+ class AdapterAuthError(AdapterError):
67
+ """Raised by connect() when credentials are invalid."""
68
+
69
+
70
+ class AdapterConnectError(AdapterError):
71
+ """Raised by connect() when the platform is unreachable."""
72
+
73
+
74
+ class AdapterSendError(AdapterError):
75
+ """Raised by send() on unrecoverable delivery failure."""
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # ChannelAdapter ABC
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ class ChannelAdapter(ABC):
84
+ """
85
+ Abstract base class for all skcomms channel adapters.
86
+
87
+ An adapter is the thin boundary between a foreign platform (Telegram,
88
+ Slack, Discord, …) and the skcomms sovereign hub. It does three things:
89
+
90
+ 1. Translate inbound platform events → ChannelMessage.
91
+ 2. Translate outbound ChannelMessage → platform API calls.
92
+ 3. Map FQID ↔ platform user/room identities.
93
+
94
+ It does NOT:
95
+ - Write to skmem-pg directly.
96
+ - Resolve FQID trust levels (that is the hub's job).
97
+ - Hold conversation state beyond what the platform provides.
98
+ - Know about CapAuth keys.
99
+
100
+ Lifecycle::
101
+
102
+ adapter = TelegramAdapter(config)
103
+ await adapter.connect() # authenticate + start polling/webhook
104
+ async for msg in adapter.inbound(): # yields normalized ChannelMessages
105
+ await hub.dispatch_inbound(msg)
106
+ await adapter.disconnect()
107
+
108
+ Subclasses must set the class-level ``channel_type`` and ``adapter_name``
109
+ attributes (not enforced by the ABC machinery so that ``__init_subclass__``
110
+ stays simple, but validated at runtime by the registry).
111
+ """
112
+
113
+ # Subclasses must set these:
114
+ channel_type: ChannelType
115
+ adapter_name: str # e.g. "telegram", "slack-sktechops"
116
+
117
+ # -----------------------------------------------------------------------
118
+ # Lifecycle
119
+ # -----------------------------------------------------------------------
120
+
121
+ @abstractmethod
122
+ async def connect(self) -> None:
123
+ """
124
+ Authenticate with the platform and start the inbound loop.
125
+
126
+ Raises:
127
+ AdapterAuthError: If credentials are invalid.
128
+ AdapterConnectError: If the platform is unreachable.
129
+ """
130
+
131
+ @abstractmethod
132
+ async def disconnect(self) -> None:
133
+ """Gracefully stop the inbound loop and close any open connections."""
134
+
135
+ @abstractmethod
136
+ async def health(self) -> AdapterHealth:
137
+ """
138
+ Return a point-in-time health snapshot.
139
+
140
+ Called by the adapter registry every 30 s; used by skmon and
141
+ the ``skcomms adapter status`` CLI subcommand.
142
+ """
143
+
144
+ # -----------------------------------------------------------------------
145
+ # Inbound (platform → skcomms)
146
+ # -----------------------------------------------------------------------
147
+
148
+ @abstractmethod
149
+ async def inbound(self) -> AsyncIterator[ChannelMessage]:
150
+ """
151
+ Async generator that yields normalized ChannelMessages as they arrive.
152
+
153
+ The hub calls ``async for msg in adapter.inbound(): …``.
154
+ Implementations may use long-polling, webhooks, or WebSocket
155
+ subscriptions — the caller does not care which.
156
+
157
+ Yields:
158
+ ChannelMessage: one per platform event.
159
+ """
160
+
161
+ # -----------------------------------------------------------------------
162
+ # Outbound (skcomms → platform)
163
+ # -----------------------------------------------------------------------
164
+
165
+ @abstractmethod
166
+ async def send(self, message: ChannelMessage) -> str:
167
+ """
168
+ Deliver a ChannelMessage to the platform.
169
+
170
+ The hub calls this after resolving the outbound route. Must handle
171
+ rate limiting internally (back off + retry up to the adapter's
172
+ configured timeout, then raise AdapterSendError).
173
+
174
+ Args:
175
+ message: Normalized outbound message. The hub has already applied
176
+ capability downgrade (e.g. converted voice to a transcript
177
+ if voice_notes=False).
178
+
179
+ Returns:
180
+ The platform's message id for the delivered message (str).
181
+
182
+ Raises:
183
+ AdapterSendError: On unrecoverable failure.
184
+ """
185
+
186
+ # -----------------------------------------------------------------------
187
+ # Identity mapping (FQID ↔ platform user/room)
188
+ # -----------------------------------------------------------------------
189
+
190
+ @abstractmethod
191
+ async def resolve_fqid(self, platform_id: PlatformIdentity) -> Optional[str]:
192
+ """
193
+ Look up the FQID bound to this platform identity.
194
+
195
+ Returns the FQID string (e.g. "chef@skworld.io") if a verified binding
196
+ exists, or None if the platform user is unknown.
197
+
198
+ The hub calls this on every inbound message and assigns a trust level
199
+ accordingly. Implementations should consult the adapter's local
200
+ identity map first, then optionally query the CapAuth DID registry.
201
+ """
202
+
203
+ @abstractmethod
204
+ async def bind_fqid(
205
+ self,
206
+ platform_id: PlatformIdentity,
207
+ fqid: str,
208
+ trust_level: str,
209
+ ) -> None:
210
+ """
211
+ Persist a verified FQID ↔ platform-id binding.
212
+
213
+ Called by the hub's identity-binding flow (e.g. when Chef types
214
+ ``/bind chef@skworld.io`` in the Telegram group and the hub verifies
215
+ the CapAuth challenge). Implementations write to the adapter's own
216
+ store (YAML / SQLite / skcapstone peers/).
217
+ """
218
+
219
+ # -----------------------------------------------------------------------
220
+ # Capabilities declaration (not abstract — safe default provided)
221
+ # -----------------------------------------------------------------------
222
+
223
+ def capabilities(self) -> AdapterCapabilities:
224
+ """
225
+ Declare what this platform supports.
226
+
227
+ Subclasses should override to return accurate flags.
228
+ The hub uses these for outbound capability downgrade.
229
+ """
230
+ return AdapterCapabilities()