ucomm 0.0.1__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.
- ucomm/__init__.py +56 -0
- ucomm/attention.py +116 -0
- ucomm/bee.py +51 -0
- ucomm/bridges/__init__.py +8 -0
- ucomm/bridges/imap.py +215 -0
- ucomm/contact.py +83 -0
- ucomm/daemon.py +149 -0
- ucomm/encoding.py +51 -0
- ucomm/envelope.py +214 -0
- ucomm/hints.py +64 -0
- ucomm/log.py +100 -0
- ucomm/profiles/__init__.py +7 -0
- ucomm/profiles/chat.py +139 -0
- ucomm/profiles/mail.py +48 -0
- ucomm/rendezvous.py +55 -0
- ucomm/signing.py +68 -0
- ucomm/store.py +105 -0
- ucomm-0.0.1.dist-info/METADATA +116 -0
- ucomm-0.0.1.dist-info/RECORD +22 -0
- ucomm-0.0.1.dist-info/WHEEL +5 -0
- ucomm-0.0.1.dist-info/licenses/LICENSE +28 -0
- ucomm-0.0.1.dist-info/top_level.txt +1 -0
ucomm/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""ucomm: universal communicator middleware for Ethereum Swarm (working title)."""
|
|
2
|
+
|
|
3
|
+
from .attention import Decision, Intensity, PolicyState, SenderContext, decide
|
|
4
|
+
from .contact import ContactCard, make_contact_card, verify_contact_card
|
|
5
|
+
from .daemon import (
|
|
6
|
+
ChannelDirectory,
|
|
7
|
+
Dashboard,
|
|
8
|
+
DashboardItem,
|
|
9
|
+
DirectoryEntry,
|
|
10
|
+
build_dashboard,
|
|
11
|
+
directory_read_state,
|
|
12
|
+
)
|
|
13
|
+
from .envelope import AttentionClaim, Envelope, EventKind, Genesis, GenesisError, TimeWindow
|
|
14
|
+
from .hints import HintSink, HintSource, InMemoryHints
|
|
15
|
+
from .log import AuthorLog, merge_causal, read_state
|
|
16
|
+
from .rendezvous import InMemoryRendezvous, Rendezvous
|
|
17
|
+
from .signing import InvalidSignature, address_of, sign_envelope, verify_envelope
|
|
18
|
+
from .store import RecordStoreAuthorLog, envelope_to_record, record_to_envelope
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AttentionClaim",
|
|
22
|
+
"AuthorLog",
|
|
23
|
+
"ChannelDirectory",
|
|
24
|
+
"ContactCard",
|
|
25
|
+
"Dashboard",
|
|
26
|
+
"DashboardItem",
|
|
27
|
+
"Decision",
|
|
28
|
+
"DirectoryEntry",
|
|
29
|
+
"Envelope",
|
|
30
|
+
"EventKind",
|
|
31
|
+
"Genesis",
|
|
32
|
+
"GenesisError",
|
|
33
|
+
"HintSink",
|
|
34
|
+
"HintSource",
|
|
35
|
+
"InMemoryHints",
|
|
36
|
+
"InMemoryRendezvous",
|
|
37
|
+
"Intensity",
|
|
38
|
+
"InvalidSignature",
|
|
39
|
+
"PolicyState",
|
|
40
|
+
"RecordStoreAuthorLog",
|
|
41
|
+
"Rendezvous",
|
|
42
|
+
"SenderContext",
|
|
43
|
+
"TimeWindow",
|
|
44
|
+
"address_of",
|
|
45
|
+
"build_dashboard",
|
|
46
|
+
"decide",
|
|
47
|
+
"directory_read_state",
|
|
48
|
+
"envelope_to_record",
|
|
49
|
+
"make_contact_card",
|
|
50
|
+
"merge_causal",
|
|
51
|
+
"read_state",
|
|
52
|
+
"record_to_envelope",
|
|
53
|
+
"sign_envelope",
|
|
54
|
+
"verify_contact_card",
|
|
55
|
+
"verify_envelope",
|
|
56
|
+
]
|
ucomm/attention.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Attention layer: priority algebra and policy engine (ATTENTION.md).
|
|
2
|
+
|
|
3
|
+
Pure and deterministic by design: decide(envelope, policy, now) -> Decision is a
|
|
4
|
+
function of its arguments only. All mutable state (ceilings, offsets, thresholds,
|
|
5
|
+
reputation ratchet) lives in PolicyState, owned by the daemon.
|
|
6
|
+
|
|
7
|
+
Receiver sovereignty: this module consumes AttentionClaim data and never
|
|
8
|
+
constructs envelopes. One-way dependency: attention -> envelope, never reverse.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import IntEnum
|
|
16
|
+
|
|
17
|
+
from .envelope import Envelope, EventKind, PubKey
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Intensity(IntEnum):
|
|
21
|
+
"""Graded notification output (ATTENTION.md section 3)."""
|
|
22
|
+
|
|
23
|
+
FILED = 0 # silently filed; dashboard history only
|
|
24
|
+
BADGE = 1 # badge / dashboard, no interruption
|
|
25
|
+
SOFT = 2 # ring once / vibrate only
|
|
26
|
+
FULL = 3 # full alert
|
|
27
|
+
BREAKTHROUGH = 4 # overrides DND
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Residual thresholds for grading, in log-scale priority units.
|
|
31
|
+
SOFT_BAND = 5 # |residual| <= SOFT_BAND counts as "near threshold"
|
|
32
|
+
BREAKTHROUGH_MARGIN = 20
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class PolicyState:
|
|
37
|
+
"""Receiver-local policy snapshot. Never published, never negotiated."""
|
|
38
|
+
|
|
39
|
+
threshold: int # current global threshold; DND = high value
|
|
40
|
+
default_ceiling_known: int = 40
|
|
41
|
+
default_ceiling_stranger: int = 0
|
|
42
|
+
wot_ceiling_slope: int = 10 # ceiling = base - slope * wot_distance (ATTENTION 5)
|
|
43
|
+
contact_ceilings: Mapping[PubKey, int] = field(default_factory=dict)
|
|
44
|
+
channel_offsets: Mapping[str, int] = field(default_factory=dict)
|
|
45
|
+
endpoint_offset: int = 0 # e.g. raised while in an interactive session
|
|
46
|
+
bond_credit: int = 10 # credibility bonus when valid collateral attached
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class SenderContext:
|
|
51
|
+
"""What the receiver knows about the sender (from identity/WoT + recsys)."""
|
|
52
|
+
|
|
53
|
+
known_contact: bool
|
|
54
|
+
wot_distance: int | None = None # None = no path
|
|
55
|
+
recsys_prior: int = 0 # advisory prior from RECOMMENDATION.md section 6
|
|
56
|
+
collateral_valid: bool = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Decision:
|
|
61
|
+
intensity: Intensity
|
|
62
|
+
effective_priority: int
|
|
63
|
+
residual: int
|
|
64
|
+
ceiling_applied: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ceiling_for(sender: PubKey, ctx: SenderContext, policy: PolicyState) -> int:
|
|
68
|
+
"""Per-sender priority ceiling (ATTENTION.md sections 2, 5)."""
|
|
69
|
+
if sender in policy.contact_ceilings:
|
|
70
|
+
return policy.contact_ceilings[sender]
|
|
71
|
+
if ctx.known_contact:
|
|
72
|
+
return policy.default_ceiling_known
|
|
73
|
+
if ctx.wot_distance is not None:
|
|
74
|
+
base = policy.default_ceiling_known
|
|
75
|
+
return max(
|
|
76
|
+
policy.default_ceiling_stranger,
|
|
77
|
+
base - policy.wot_ceiling_slope * ctx.wot_distance + ctx.recsys_prior,
|
|
78
|
+
)
|
|
79
|
+
return policy.default_ceiling_stranger + ctx.recsys_prior
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def decide(env: Envelope, ctx: SenderContext, policy: PolicyState, now: float) -> Decision:
|
|
83
|
+
"""Map one control-plane envelope to a graded notification intensity.
|
|
84
|
+
|
|
85
|
+
Pure function: (envelope, sender context, policy state, clock) -> Decision.
|
|
86
|
+
"""
|
|
87
|
+
if env.kind is not EventKind.INVITATION or env.attention is None:
|
|
88
|
+
return Decision(Intensity.FILED, 0, -(10**6), 0)
|
|
89
|
+
|
|
90
|
+
claim = env.attention
|
|
91
|
+
if not claim.relevance.active(now):
|
|
92
|
+
return Decision(Intensity.FILED, 0, -(10**6), 0) # expired -> timeline
|
|
93
|
+
|
|
94
|
+
cap = ceiling_for(env.author, ctx, policy)
|
|
95
|
+
credibility = policy.bond_credit if ctx.collateral_valid else 0
|
|
96
|
+
|
|
97
|
+
effective = (
|
|
98
|
+
min(claim.claimed_priority, cap)
|
|
99
|
+
+ policy.channel_offsets.get(env.channel, 0)
|
|
100
|
+
+ policy.endpoint_offset
|
|
101
|
+
+ credibility
|
|
102
|
+
)
|
|
103
|
+
residual = effective - policy.threshold
|
|
104
|
+
|
|
105
|
+
if residual > BREAKTHROUGH_MARGIN:
|
|
106
|
+
intensity = Intensity.BREAKTHROUGH
|
|
107
|
+
elif residual > SOFT_BAND:
|
|
108
|
+
intensity = Intensity.FULL
|
|
109
|
+
elif residual > 0:
|
|
110
|
+
intensity = Intensity.SOFT
|
|
111
|
+
elif residual > -SOFT_BAND:
|
|
112
|
+
intensity = Intensity.BADGE
|
|
113
|
+
else:
|
|
114
|
+
intensity = Intensity.FILED
|
|
115
|
+
|
|
116
|
+
return Decision(intensity, effective, residual, cap)
|
ucomm/bee.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Real Swarm-backed author logs (M1).
|
|
2
|
+
|
|
3
|
+
Thin adapter from `RecordStoreAuthorLog` (`ucomm.store`) to recordstore's Bee
|
|
4
|
+
backends -- `BeeBytesStore` + `SwarmFeedPointer` -- instead of the in-memory
|
|
5
|
+
ones used for M0 prototyping. This is the "drop-in swap, not a rewrite"
|
|
6
|
+
ROADMAP.md promised: `RecordStoreAuthorLog` only ever needed a
|
|
7
|
+
`recordstore.RecordStore`; it never knew or cared whether that store was
|
|
8
|
+
backed by memory or a live Bee node.
|
|
9
|
+
|
|
10
|
+
Feeds need an IMMUTABLE postage batch (see CLAUDE.md, "Swarm facts to
|
|
11
|
+
respect"). This module never buys one -- pass an existing usable batch id
|
|
12
|
+
(`GET /stamps` on the target node), bought e.g. with
|
|
13
|
+
`POST /stamps/{amount}/{depth}?immutable=true` or swarmfs's `StampManager`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from recordstore import BeeBytesStore, RecordStore, SwarmFeedPointer
|
|
19
|
+
|
|
20
|
+
from .envelope import ChannelId, PubKey
|
|
21
|
+
from .signing import address_of
|
|
22
|
+
from .store import RecordStoreAuthorLog
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def feed_topic(channel: ChannelId, author: PubKey) -> str:
|
|
26
|
+
"""Feed topic namespacing one author's per-channel log."""
|
|
27
|
+
return f"ucomm/log/{channel}/{author}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def open_author_feed_log(
|
|
31
|
+
api_url: str, channel: ChannelId, *, signer_hex: str, postage_batch_id: str,
|
|
32
|
+
) -> tuple[RecordStore, RecordStoreAuthorLog]:
|
|
33
|
+
"""Open `signer_hex`'s own log for `channel`, backed by a real Swarm feed.
|
|
34
|
+
|
|
35
|
+
`signer_hex` is the author's private key (hex); the feed owner (and
|
|
36
|
+
hence `Envelope.author`, via `ucomm.signing.address_of`) is derived from
|
|
37
|
+
it. Returns `(store, log)`: append through `log`, then call
|
|
38
|
+
`store.commit()` to actually publish -- appends are staged locally until
|
|
39
|
+
then, same as any other `RecordStore` (issue K-4's batching model, not a
|
|
40
|
+
new one for Bee).
|
|
41
|
+
"""
|
|
42
|
+
from bee.swarm.keys import PrivateKey
|
|
43
|
+
|
|
44
|
+
author: PubKey = address_of(PrivateKey.from_hex(signer_hex))
|
|
45
|
+
pointer = SwarmFeedPointer(
|
|
46
|
+
api_url, feed_topic(channel, author),
|
|
47
|
+
signer=signer_hex, postage_batch_id=postage_batch_id,
|
|
48
|
+
)
|
|
49
|
+
bytes_store = BeeBytesStore(api_url, postage_batch_id=postage_batch_id)
|
|
50
|
+
store = RecordStore(bytes_store, pointer=pointer)
|
|
51
|
+
return store, RecordStoreAuthorLog(store, channel, author)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Bridges: adapters converting external protocols into ucomm envelopes.
|
|
2
|
+
|
|
3
|
+
DESIGN.md section 10: every bridge is an inbox adapter into the same
|
|
4
|
+
envelope schema and policy engine ucomm uses natively -- the "attention
|
|
5
|
+
firewall" is a unified inbox across everything, not just Swarm-native
|
|
6
|
+
channels. Kept out of the top-level `ucomm` namespace, same reasoning as
|
|
7
|
+
`ucomm.profiles`: a layer above the kernel, not part of it.
|
|
8
|
+
"""
|
ucomm/bridges/imap.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""IMAP bridge: envelope adapter for email (issue D-5).
|
|
2
|
+
|
|
3
|
+
DESIGN.md section 10: bridges convert an external protocol's events into
|
|
4
|
+
the same envelope schema and policy engine ucomm uses natively, so the
|
|
5
|
+
attention firewall covers inboxes that already exist. This module has two
|
|
6
|
+
layers:
|
|
7
|
+
|
|
8
|
+
- **Pure conversion** (`envelope_from_email`, `invitation_for_email`,
|
|
9
|
+
`bridge_author`): raw email -> envelope(s), fully offline and
|
|
10
|
+
unit-testable with synthetic messages (stdlib `email` objects) -- no
|
|
11
|
+
network. This is the part with real test coverage against arbitrary
|
|
12
|
+
input.
|
|
13
|
+
- **Live fetch** (`ImapMailbox`): connects to a real mailbox via
|
|
14
|
+
`IMAPClient` (per CLAUDE.md's "use established libraries, not raw
|
|
15
|
+
`imaplib`" convention) and calls the pure layer per message. This part
|
|
16
|
+
is only unit-tested against a fake client double -- it hasn't been
|
|
17
|
+
verified against a real IMAP server yet (no test mailbox available when
|
|
18
|
+
this was written), the same caveat `ucomm.bee` had before its first live
|
|
19
|
+
Bee run.
|
|
20
|
+
|
|
21
|
+
Bridged authorship is NOT the same guarantee as a native ucomm signature.
|
|
22
|
+
An email's `From:` address is only as trustworthy as SMTP/DKIM made it --
|
|
23
|
+
ucomm doesn't re-verify that. Bridged envelopes are left unsigned
|
|
24
|
+
(`sig=""`); `verify_envelope` on one is always `False`, honestly, never
|
|
25
|
+
silently treated as verified.
|
|
26
|
+
|
|
27
|
+
Every bridged email gets a paired `INVITATION` (DESIGN.md section 3.2, two
|
|
28
|
+
planes -- the message body is content, the invitation is a small pointer
|
|
29
|
+
to it, `refs`-only, no payload duplication) with a naive default
|
|
30
|
+
`AttentionClaim`. That claim is exactly as advisory as any sender's claim
|
|
31
|
+
ever is (CLAUDE.md invariant 1): the receiver's own policy engine decides
|
|
32
|
+
how loud a bridged email actually is, not the quality of this default.
|
|
33
|
+
|
|
34
|
+
This bridge is read-only by design (IMAP fetch only, `select_folder(...,
|
|
35
|
+
readonly=True)`) -- it ingests existing mail into the unified dashboard,
|
|
36
|
+
it does not send. Composing/replying would need SMTP, a distinct and
|
|
37
|
+
separate capability this module doesn't provide.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import hashlib
|
|
43
|
+
from email import message_from_bytes
|
|
44
|
+
from email.message import Message
|
|
45
|
+
from email.utils import parsedate_to_datetime
|
|
46
|
+
from typing import cast
|
|
47
|
+
|
|
48
|
+
from imapclient import IMAPClient
|
|
49
|
+
|
|
50
|
+
from ..envelope import (
|
|
51
|
+
AttentionClaim,
|
|
52
|
+
ChannelId,
|
|
53
|
+
Envelope,
|
|
54
|
+
EventHash,
|
|
55
|
+
EventKind,
|
|
56
|
+
MediaDescriptor,
|
|
57
|
+
PubKey,
|
|
58
|
+
TimeWindow,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
_BRIDGE_AUTHOR_PREFIX = "bridge:imap:"
|
|
62
|
+
|
|
63
|
+
DEFAULT_RELEVANCE_SECONDS = 7 * 24 * 3600 # a week: mail is async, not urgent by default
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def bridge_author(email_address: str) -> PubKey:
|
|
67
|
+
"""A deterministic, clearly-namespaced pseudo-author for a bridged sender.
|
|
68
|
+
|
|
69
|
+
Not a real signing key's address -- visibly different in shape (a
|
|
70
|
+
`bridge:imap:` prefix) so it can never be confused with, or collide
|
|
71
|
+
with, one. Bridged envelopes are unsigned; nothing should treat this
|
|
72
|
+
as an authenticated identity.
|
|
73
|
+
"""
|
|
74
|
+
digest = hashlib.sha256(email_address.strip().lower().encode("utf-8")).hexdigest()
|
|
75
|
+
return f"{_BRIDGE_AUTHOR_PREFIX}{digest}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _extract_body(msg: Message) -> tuple[str, str]:
|
|
79
|
+
"""Return (mime type, text) for the best available non-attachment part.
|
|
80
|
+
|
|
81
|
+
Prefers text/plain over text/html, matching the mail profile's
|
|
82
|
+
text-only media declaration; either can be added properly (real
|
|
83
|
+
multipart/attachment media kinds) once a profile needs it.
|
|
84
|
+
"""
|
|
85
|
+
if msg.is_multipart():
|
|
86
|
+
for wanted in ("text/plain", "text/html"):
|
|
87
|
+
for part in msg.walk():
|
|
88
|
+
if part.get_content_type() == wanted and not part.get_filename():
|
|
89
|
+
# get_payload(decode=True) always returns bytes|None here;
|
|
90
|
+
# the stub's wider return type doesn't narrow on `decode`.
|
|
91
|
+
payload = cast("bytes | None", part.get_payload(decode=True)) or b""
|
|
92
|
+
charset = part.get_content_charset() or "utf-8"
|
|
93
|
+
return wanted, payload.decode(charset, errors="replace")
|
|
94
|
+
return "text/plain", ""
|
|
95
|
+
payload = cast("bytes | None", msg.get_payload(decode=True)) or b""
|
|
96
|
+
charset = msg.get_content_charset() or "utf-8"
|
|
97
|
+
return msg.get_content_type() or "text/plain", payload.decode(charset, errors="replace")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _received_at(msg: Message, *, default: float) -> float:
|
|
101
|
+
date_header = msg.get("Date")
|
|
102
|
+
if date_header:
|
|
103
|
+
try:
|
|
104
|
+
return parsedate_to_datetime(date_header).timestamp()
|
|
105
|
+
except (TypeError, ValueError):
|
|
106
|
+
pass
|
|
107
|
+
return default
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def envelope_from_email(
|
|
111
|
+
msg: Message, channel: ChannelId, seq: int, refs: tuple[EventHash, ...] = (),
|
|
112
|
+
) -> Envelope:
|
|
113
|
+
"""Convert one email into a `MESSAGE` envelope. Pure; no network."""
|
|
114
|
+
sender = msg.get("From", "")
|
|
115
|
+
mime, body = _extract_body(msg)
|
|
116
|
+
return Envelope(
|
|
117
|
+
channel=channel, author=bridge_author(sender), seq=seq,
|
|
118
|
+
kind=EventKind.MESSAGE, refs=refs,
|
|
119
|
+
media=MediaDescriptor(mime=mime), inline=body.encode("utf-8"),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def invitation_for_email(
|
|
124
|
+
msg: Message, channel: ChannelId, seq: int, message_hash: EventHash, now: float,
|
|
125
|
+
*, importance: int = 5, urgency: int = 5,
|
|
126
|
+
relevance_seconds: float = DEFAULT_RELEVANCE_SECONDS,
|
|
127
|
+
) -> Envelope:
|
|
128
|
+
"""The paired attention request for a bridged email.
|
|
129
|
+
|
|
130
|
+
`refs=(message_hash,)` only -- a small pointer to the message envelope,
|
|
131
|
+
never the body itself (two planes, CLAUDE.md invariant 3). `now` is the
|
|
132
|
+
fallback "received at" when the email has no parseable `Date:` header.
|
|
133
|
+
"""
|
|
134
|
+
sender = msg.get("From", "")
|
|
135
|
+
received = _received_at(msg, default=now)
|
|
136
|
+
return Envelope(
|
|
137
|
+
channel=channel, author=bridge_author(sender), seq=seq,
|
|
138
|
+
kind=EventKind.INVITATION, refs=(message_hash,),
|
|
139
|
+
attention=AttentionClaim(
|
|
140
|
+
importance=importance, urgency=urgency,
|
|
141
|
+
relevance=TimeWindow(start=received, end=received + relevance_seconds),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ImapMailbox:
|
|
147
|
+
"""Live IMAP fetch loop: pulls new mail via `IMAPClient` and converts
|
|
148
|
+
each message with the pure functions above.
|
|
149
|
+
|
|
150
|
+
Uses UIDs, not sequence numbers, to track what's new -- UIDs are
|
|
151
|
+
stable across sessions per IMAP semantics, sequence numbers aren't.
|
|
152
|
+
Fetch state (last-seen UID, next `seq` per bridged author) lives on
|
|
153
|
+
this object, in-process only; persisting it across restarts (so a
|
|
154
|
+
fresh run doesn't re-fetch everything) is separate, not-yet-built
|
|
155
|
+
work -- the same "in-memory first" step `AuthorLog` went through
|
|
156
|
+
before `RecordStoreAuthorLog`.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(self, client: IMAPClient, channel: ChannelId) -> None:
|
|
160
|
+
self._client = client
|
|
161
|
+
self.channel = channel
|
|
162
|
+
self._last_uid: int | None = None
|
|
163
|
+
self._next_seq: dict[PubKey, int] = {}
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def connect(
|
|
167
|
+
cls, host: str, username: str, password: str, channel: ChannelId,
|
|
168
|
+
*, folder: str = "INBOX", port: int | None = None, ssl: bool = True,
|
|
169
|
+
) -> ImapMailbox:
|
|
170
|
+
"""Connect, log in, and select `folder` read-only -- this bridge
|
|
171
|
+
only ever reads (see the module docstring: composing/sending
|
|
172
|
+
would need SMTP, which this doesn't provide)."""
|
|
173
|
+
client = IMAPClient(host, port=port, ssl=ssl, use_uid=True)
|
|
174
|
+
client.login(username, password)
|
|
175
|
+
client.select_folder(folder, readonly=True)
|
|
176
|
+
return cls(client, channel)
|
|
177
|
+
|
|
178
|
+
def close(self) -> None:
|
|
179
|
+
self._client.logout()
|
|
180
|
+
|
|
181
|
+
def fetch_new(self, now: float) -> list[Envelope]:
|
|
182
|
+
"""Fetch and convert messages newer than the last call.
|
|
183
|
+
|
|
184
|
+
Advances internal state; safe to call repeatedly as a poll loop.
|
|
185
|
+
Returns a flat `[message, invitation, message, invitation, ...]`
|
|
186
|
+
list, in the shape `ucomm.daemon.build_dashboard`/
|
|
187
|
+
`directory_read_state` already expect as one channel's events.
|
|
188
|
+
"""
|
|
189
|
+
uids = self._new_uids()
|
|
190
|
+
if not uids:
|
|
191
|
+
return []
|
|
192
|
+
fetched = self._client.fetch(uids, ["RFC822"])
|
|
193
|
+
envelopes: list[Envelope] = []
|
|
194
|
+
for uid in uids:
|
|
195
|
+
msg = message_from_bytes(fetched[uid][b"RFC822"])
|
|
196
|
+
author = bridge_author(msg.get("From", ""))
|
|
197
|
+
seq = self._next_seq.get(author, 1)
|
|
198
|
+
message_env = envelope_from_email(msg, self.channel, seq)
|
|
199
|
+
invitation = invitation_for_email(
|
|
200
|
+
msg, self.channel, seq + 1, message_env.event_hash(), now,
|
|
201
|
+
)
|
|
202
|
+
envelopes.extend((message_env, invitation))
|
|
203
|
+
self._next_seq[author] = seq + 2
|
|
204
|
+
self._last_uid = uid
|
|
205
|
+
return envelopes
|
|
206
|
+
|
|
207
|
+
def _new_uids(self) -> list[int]:
|
|
208
|
+
if self._last_uid is None:
|
|
209
|
+
return sorted(self._client.search(["ALL"]))
|
|
210
|
+
uids = self._client.search(["UID", f"{self._last_uid + 1}:*"])
|
|
211
|
+
# RFC 3501 section 9: a "n:*" range is defined to include the
|
|
212
|
+
# mailbox's highest UID even when nothing is actually >= n --
|
|
213
|
+
# filter that back out rather than re-processing the top message
|
|
214
|
+
# on every poll that finds nothing new.
|
|
215
|
+
return sorted(uid for uid in uids if uid > self._last_uid)
|
ucomm/contact.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Out-of-band contact exchange (M1).
|
|
2
|
+
|
|
3
|
+
Before any channel exists, two parties need a minimal way to hand each
|
|
4
|
+
other an address they can trust really corresponds to a key someone
|
|
5
|
+
controls, over a transport with no verification of its own -- a pasted
|
|
6
|
+
string, a QR code, a business card. `ContactCard` is that minimum: an
|
|
7
|
+
address plus a self-attestation signature, domain-separated from envelope
|
|
8
|
+
signing so a card can never be replayed as (or forged from) a signed
|
|
9
|
+
envelope. It catches a copy/paste error or an active tamperer on the
|
|
10
|
+
exchange channel before any channel or message exists.
|
|
11
|
+
|
|
12
|
+
This is deliberately NOT the identity-wot library (ROADMAP.md: root keys,
|
|
13
|
+
device delegation, petnames, attestations -- a standalone project tracking
|
|
14
|
+
the ecosystem "Swarm ID" work). No delegation, no petnames (DESIGN.md
|
|
15
|
+
section 7: petnames are local, never part of what's exchanged), no
|
|
16
|
+
rendezvous (that's issue K-5 / milestone M3, for *unsolicited* contact --
|
|
17
|
+
this is for two parties already coordinating out-of-band). Just: prove
|
|
18
|
+
control of an address before anyone builds a channel around it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
from bee.swarm.errors import BeeError
|
|
26
|
+
from bee.swarm.keys import PrivateKey, verify_signature
|
|
27
|
+
from bee.swarm.typed_bytes import EthAddress, Signature
|
|
28
|
+
|
|
29
|
+
from .envelope import PubKey
|
|
30
|
+
from .signing import address_of
|
|
31
|
+
|
|
32
|
+
_DOMAIN = b"ucomm-contact-card-v1:"
|
|
33
|
+
_STR_PREFIX = "ucomm-contact-v1:"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _card_bytes(address: PubKey) -> bytes:
|
|
37
|
+
"""Domain-separated from `ucomm.signing`'s envelope bytes: an envelope
|
|
38
|
+
signature can never be replayed as a contact card, or vice versa."""
|
|
39
|
+
return _DOMAIN + address.encode("ascii")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ContactCard:
|
|
44
|
+
"""A self-attested address: proof its holder controls the signing key.
|
|
45
|
+
|
|
46
|
+
Not proof of *who* holds it -- that's for the receiver's own local
|
|
47
|
+
petname/WoT judgment (DESIGN.md section 7), never something a card
|
|
48
|
+
asserts about itself.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
address: PubKey
|
|
52
|
+
sig: str
|
|
53
|
+
|
|
54
|
+
def to_str(self) -> str:
|
|
55
|
+
"""Compact form safe to paste, email, or encode as a QR code."""
|
|
56
|
+
return f"{_STR_PREFIX}{self.address}:{self.sig}"
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_str(cls, s: str) -> ContactCard:
|
|
60
|
+
if not s.startswith(_STR_PREFIX):
|
|
61
|
+
raise ValueError(f"not a ucomm contact card: {s!r}")
|
|
62
|
+
rest = s[len(_STR_PREFIX):]
|
|
63
|
+
address, sep, sig = rest.partition(":")
|
|
64
|
+
if not sep:
|
|
65
|
+
raise ValueError(f"malformed contact card: {s!r}")
|
|
66
|
+
return cls(address=address, sig=sig)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def make_contact_card(key: PrivateKey) -> ContactCard:
|
|
70
|
+
"""Self-attest `key`'s address: proof of control, not proof of identity."""
|
|
71
|
+
address = address_of(key)
|
|
72
|
+
signature = key.sign(_card_bytes(address))
|
|
73
|
+
return ContactCard(address=address, sig=signature.to_hex())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def verify_contact_card(card: ContactCard) -> bool:
|
|
77
|
+
"""True iff `card.sig` really proves control of `card.address`."""
|
|
78
|
+
try:
|
|
79
|
+
signature = Signature.from_hex(card.sig)
|
|
80
|
+
expected = EthAddress.from_hex(card.address)
|
|
81
|
+
except BeeError:
|
|
82
|
+
return False
|
|
83
|
+
return verify_signature(signature, _card_bytes(card.address), expected)
|
ucomm/daemon.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Notification daemon core: channel directory + graded dashboard (M2).
|
|
2
|
+
|
|
3
|
+
DESIGN.md section 10: the user keeps a private channel directory (every
|
|
4
|
+
channel they participate in, across all apps, with local per-channel policy
|
|
5
|
+
overrides); one notification daemon per device computes effective priority
|
|
6
|
+
over it and emits a graded dashboard -- active requests ordered by priority
|
|
7
|
+
(including sub-threshold ones, silently filed), plus an obsolete timeline for
|
|
8
|
+
expired ones.
|
|
9
|
+
|
|
10
|
+
Resolves the open question in DESIGN.md section 12: the dashboard is a
|
|
11
|
+
projection, not authoritative state. `build_dashboard` is a pure function of
|
|
12
|
+
(directory, channel events, policy, sender contexts, clock) -- like
|
|
13
|
+
`ucomm.attention.decide`, its output is never stored, only recomputed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from .attention import Decision, PolicyState, SenderContext, decide
|
|
22
|
+
from .envelope import ChannelId, Envelope, EventHash, EventKind, PubKey
|
|
23
|
+
from .log import merge_causal, read_state
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class DirectoryEntry:
|
|
28
|
+
"""One participated channel, with local-only policy overrides.
|
|
29
|
+
|
|
30
|
+
Mute is a large negative `channel_offset`, not a separate mechanism
|
|
31
|
+
(ATTENTION.md section 2 / DESIGN.md section 11) -- so there's no
|
|
32
|
+
`muted` field here to duplicate it.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
channel: ChannelId
|
|
36
|
+
profile: str | None = None
|
|
37
|
+
channel_offset: int = 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ChannelDirectory:
|
|
41
|
+
"""Every channel the user participates in, across all apps (DESIGN.md
|
|
42
|
+
section 10). Purely local and private: never signed, never synced --
|
|
43
|
+
unlike the channels it lists, the directory itself has no merge rule,
|
|
44
|
+
because there's exactly one writer, the local user.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._entries: dict[ChannelId, DirectoryEntry] = {}
|
|
49
|
+
|
|
50
|
+
def add(self, entry: DirectoryEntry) -> None:
|
|
51
|
+
self._entries[entry.channel] = entry
|
|
52
|
+
|
|
53
|
+
def remove(self, channel: ChannelId) -> None:
|
|
54
|
+
self._entries.pop(channel, None)
|
|
55
|
+
|
|
56
|
+
def __iter__(self) -> Iterator[DirectoryEntry]:
|
|
57
|
+
return iter(self._entries.values())
|
|
58
|
+
|
|
59
|
+
def __contains__(self, channel: ChannelId) -> bool:
|
|
60
|
+
return channel in self._entries
|
|
61
|
+
|
|
62
|
+
def __len__(self) -> int:
|
|
63
|
+
return len(self._entries)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def channel_offsets(self) -> dict[ChannelId, int]:
|
|
67
|
+
"""Directory-derived offsets, ready to merge into a `PolicyState`."""
|
|
68
|
+
return {e.channel: e.channel_offset for e in self._entries.values()}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class DashboardItem:
|
|
73
|
+
channel: ChannelId
|
|
74
|
+
envelope: Envelope
|
|
75
|
+
decision: Decision
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Dashboard:
|
|
80
|
+
"""A graded view over active/obsolete attention requests -- a
|
|
81
|
+
projection, never persisted (DESIGN.md section 12)."""
|
|
82
|
+
|
|
83
|
+
active: tuple[DashboardItem, ...] # relevance still active; sorted by
|
|
84
|
+
# effective_priority, highest first
|
|
85
|
+
obsolete: tuple[DashboardItem, ...] # relevance window has passed
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_dashboard(
|
|
89
|
+
directory: ChannelDirectory,
|
|
90
|
+
channel_events: Mapping[ChannelId, Iterable[Envelope]],
|
|
91
|
+
policy: PolicyState,
|
|
92
|
+
sender_contexts: Mapping[PubKey, SenderContext],
|
|
93
|
+
now: float,
|
|
94
|
+
*,
|
|
95
|
+
default_sender_context: SenderContext | None = None,
|
|
96
|
+
) -> Dashboard:
|
|
97
|
+
"""Compute the dashboard fresh from current state.
|
|
98
|
+
|
|
99
|
+
Never store the result -- call this again next time. It's cheap, and
|
|
100
|
+
recomputing is the only way the dashboard can't drift from the channels
|
|
101
|
+
and policy it's derived from (same discipline as `decide()` itself).
|
|
102
|
+
|
|
103
|
+
Only `INVITATION` envelopes carrying an `AttentionClaim` are dashboard
|
|
104
|
+
material -- ordinary messages aren't "requests" in DESIGN.md section 10's
|
|
105
|
+
sense and are silently skipped, matching `decide()`'s own treatment of
|
|
106
|
+
them (a trivial FILED sentinel) rather than cluttering the timeline.
|
|
107
|
+
|
|
108
|
+
Callers pass unmerged events (e.g. every member's raw log); each
|
|
109
|
+
channel's stream is merged internally, so a duplicate delivery or an
|
|
110
|
+
event reachable through two members' logs is counted once, not twice.
|
|
111
|
+
"""
|
|
112
|
+
unknown_ctx = default_sender_context or SenderContext(known_contact=False)
|
|
113
|
+
active: list[DashboardItem] = []
|
|
114
|
+
obsolete: list[DashboardItem] = []
|
|
115
|
+
|
|
116
|
+
for entry in directory:
|
|
117
|
+
for env in merge_causal(channel_events.get(entry.channel, ())):
|
|
118
|
+
if env.kind is not EventKind.INVITATION or env.attention is None:
|
|
119
|
+
continue
|
|
120
|
+
ctx = sender_contexts.get(env.author, unknown_ctx)
|
|
121
|
+
item = DashboardItem(entry.channel, env, decide(env, ctx, policy, now))
|
|
122
|
+
if env.attention.relevance.active(now):
|
|
123
|
+
active.append(item)
|
|
124
|
+
else:
|
|
125
|
+
obsolete.append(item)
|
|
126
|
+
|
|
127
|
+
active.sort(key=lambda item: item.decision.effective_priority, reverse=True)
|
|
128
|
+
return Dashboard(active=tuple(active), obsolete=tuple(obsolete))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def directory_read_state(
|
|
132
|
+
directory: ChannelDirectory,
|
|
133
|
+
channel_events: Mapping[ChannelId, Iterable[Envelope]],
|
|
134
|
+
) -> dict[ChannelId, dict[PubKey, EventHash]]:
|
|
135
|
+
"""Read-state (`ucomm.log.read_state`) for every channel in the
|
|
136
|
+
directory, rolled up into one view (issue D-3).
|
|
137
|
+
|
|
138
|
+
`ucomm.profiles.chat.ChatChannel.read_state` already does this for one
|
|
139
|
+
channel; the daemon needs it across every channel a user is in,
|
|
140
|
+
regardless of profile -- possible without profile-specific code because
|
|
141
|
+
`RECEIPT`'s meaning (`ucomm.log.read_state`'s docstring) is a kernel
|
|
142
|
+
convention, not a chat-profile one. Callers pass unmerged events; each
|
|
143
|
+
channel's stream is merged internally so callers don't have to remember
|
|
144
|
+
to do it themselves.
|
|
145
|
+
"""
|
|
146
|
+
return {
|
|
147
|
+
entry.channel: read_state(merge_causal(channel_events.get(entry.channel, ())))
|
|
148
|
+
for entry in directory
|
|
149
|
+
}
|