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.
- skcomms/__init__.py +58 -0
- skcomms/ack.py +296 -0
- skcomms/adapters/__init__.py +91 -0
- skcomms/adapters/base.py +230 -0
- skcomms/adapters/discord.py +808 -0
- skcomms/adapters/factory.py +145 -0
- skcomms/adapters/fake.py +119 -0
- skcomms/adapters/matrix.py +816 -0
- skcomms/adapters/models.py +127 -0
- skcomms/adapters/registry.py +277 -0
- skcomms/adapters/slack.py +549 -0
- skcomms/adapters/telegram.py +882 -0
- skcomms/api.py +1983 -0
- skcomms/capauth_validator.py +447 -0
- skcomms/cli.py +2554 -0
- skcomms/cluster.py +67 -0
- skcomms/compression.py +233 -0
- skcomms/config.py +156 -0
- skcomms/core.py +768 -0
- skcomms/crypto.py +368 -0
- skcomms/did_router.py +325 -0
- skcomms/discovery.py +535 -0
- skcomms/envelope.py +125 -0
- skcomms/glossa/__init__.py +9 -0
- skcomms/glossa/codebook.py +40 -0
- skcomms/glossa/codec.py +71 -0
- skcomms/glossa/emergent.py +90 -0
- skcomms/glossa/gloss.py +44 -0
- skcomms/glossa/handshake.py +45 -0
- skcomms/glossa/macros.py +83 -0
- skcomms/glossa/message.py +21 -0
- skcomms/glossa/session.py +84 -0
- skcomms/grants.py +455 -0
- skcomms/heartbeat.py +735 -0
- skcomms/home.py +138 -0
- skcomms/household_router.py +234 -0
- skcomms/identity.py +72 -0
- skcomms/integration.py +187 -0
- skcomms/key_exchange.py +541 -0
- skcomms/mailbox.py +274 -0
- skcomms/marketplace.py +359 -0
- skcomms/mcp_server.py +506 -0
- skcomms/metrics.py +243 -0
- skcomms/models.py +235 -0
- skcomms/outbox.py +420 -0
- skcomms/pairing.py +227 -0
- skcomms/peers.py +234 -0
- skcomms/profile_router.py +557 -0
- skcomms/pubsub.py +439 -0
- skcomms/pubsub_transport.py +294 -0
- skcomms/queue.py +334 -0
- skcomms/ratelimit.py +242 -0
- skcomms/realm.py +78 -0
- skcomms/registry.py +576 -0
- skcomms/router.py +553 -0
- skcomms/signaling.py +465 -0
- skcomms/signing.py +309 -0
- skcomms/souls_router.py +622 -0
- skcomms/tofu.py +219 -0
- skcomms/transport.py +144 -0
- skcomms/transports/__init__.py +6 -0
- skcomms/transports/audio_track.py +240 -0
- skcomms/transports/ble/__init__.py +8 -0
- skcomms/transports/ble/gatt.py +16 -0
- skcomms/transports/ble/identity.py +85 -0
- skcomms/transports/ble/node.py +80 -0
- skcomms/transports/ble/noise.py +96 -0
- skcomms/transports/ble/protocol.py +200 -0
- skcomms/transports/ble/radio.py +88 -0
- skcomms/transports/ble/relay.py +68 -0
- skcomms/transports/broker_server.py +50 -0
- skcomms/transports/file.py +575 -0
- skcomms/transports/lora/__init__.py +9 -0
- skcomms/transports/lora/addressing.py +52 -0
- skcomms/transports/lora/framing.py +32 -0
- skcomms/transports/lora/interface.py +90 -0
- skcomms/transports/lora/meshtastic_iface.py +61 -0
- skcomms/transports/lora/store.py +88 -0
- skcomms/transports/lora/transport.py +147 -0
- skcomms/transports/nostr.py +822 -0
- skcomms/transports/p2p_connector.py +101 -0
- skcomms/transports/p2p_manager.py +127 -0
- skcomms/transports/p2p_session.py +162 -0
- skcomms/transports/signaling_base.py +52 -0
- skcomms/transports/signaling_broker.py +111 -0
- skcomms/transports/signaling_mailbox.py +118 -0
- skcomms/transports/syncthing.py +513 -0
- skcomms/transports/tailscale.py +633 -0
- skcomms/transports/video_track.py +235 -0
- skcomms/transports/webrtc.py +1097 -0
- skcomms/transports/webrtc_media.py +716 -0
- skcomms/transports/websocket.py +507 -0
- skcomms-0.1.3.dist-info/METADATA +327 -0
- skcomms-0.1.3.dist-info/RECORD +98 -0
- skcomms-0.1.3.dist-info/WHEEL +5 -0
- skcomms-0.1.3.dist-info/entry_points.txt +3 -0
- skcomms-0.1.3.dist-info/licenses/LICENSE +674 -0
- 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
|
+
]
|
skcomms/adapters/base.py
ADDED
|
@@ -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()
|