correspond 0.0.2__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.
- correspond/__init__.py +113 -0
- correspond/__main__.py +106 -0
- correspond/channels/__init__.py +6 -0
- correspond/channels/_http.py +131 -0
- correspond/channels/github.py +1151 -0
- correspond/channels/macos.py +119 -0
- correspond/channels/mail.py +613 -0
- correspond/channels/ntfy.py +210 -0
- correspond/channels/telegram.py +443 -0
- correspond/channels/webinbox.py +814 -0
- correspond/data/skills/correspond/SKILL.md +100 -0
- correspond/errors.py +111 -0
- correspond/mcp.py +111 -0
- correspond/model.py +705 -0
- correspond/ops.py +497 -0
- correspond/registry.py +601 -0
- correspond/render.py +51 -0
- correspond/routing.py +227 -0
- correspond/settings.py +154 -0
- correspond/stores.py +169 -0
- correspond/testing.py +196 -0
- correspond/tools.py +362 -0
- correspond-0.0.2.dist-info/METADATA +201 -0
- correspond-0.0.2.dist-info/RECORD +27 -0
- correspond-0.0.2.dist-info/WHEEL +4 -0
- correspond-0.0.2.dist-info/entry_points.txt +3 -0
- correspond-0.0.2.dist-info/licenses/LICENSE +21 -0
correspond/__init__.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""correspond: a channel facade for AI agents.
|
|
2
|
+
|
|
3
|
+
Read, listen, write and identify the sender over GitHub, email, push notifications,
|
|
4
|
+
Telegram and a web inbox, through one model and one set of verbs::
|
|
5
|
+
|
|
6
|
+
>>> import correspond # doctest: +SKIP
|
|
7
|
+
>>> messages = correspond.read("github:octocat/hello-world#1") # doctest: +SKIP
|
|
8
|
+
>>> messages[0].author.handle, messages[0].authenticity.grade.value # doctest: +SKIP
|
|
9
|
+
('octocat', 'platform')
|
|
10
|
+
>>> correspond.send("ntfy:", "backup finished", dry_run=True).plan # doctest: +SKIP
|
|
11
|
+
|
|
12
|
+
A conversation reference is ``<channel>:<id>``. Each channel implements the operations it
|
|
13
|
+
can (``read``, ``listen``, ``send``, ``edit``, ``react``, ``upload``, ``verify``); asking
|
|
14
|
+
for one it lacks raises :class:`NotSupported`, and :func:`capabilities` says so in advance.
|
|
15
|
+
correspond knows no people: a message's ``author`` is what the platform attests, and its
|
|
16
|
+
``authenticity`` is how sure the channel is.
|
|
17
|
+
|
|
18
|
+
The command line (``correspond read github:octocat/hello-world#1``) and the MCP server use
|
|
19
|
+
the same verbs, through :mod:`correspond.tools`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from correspond.errors import (
|
|
23
|
+
ChannelError,
|
|
24
|
+
CorrespondError,
|
|
25
|
+
InvalidRef,
|
|
26
|
+
MissingRequirement,
|
|
27
|
+
NotSupported,
|
|
28
|
+
UnknownChannel,
|
|
29
|
+
)
|
|
30
|
+
from correspond.model import (
|
|
31
|
+
Account,
|
|
32
|
+
Attachment,
|
|
33
|
+
Authenticity,
|
|
34
|
+
Capabilities,
|
|
35
|
+
ChannelIdentity,
|
|
36
|
+
ConversationRef,
|
|
37
|
+
Draft,
|
|
38
|
+
Event,
|
|
39
|
+
Grade,
|
|
40
|
+
HistoryDepth,
|
|
41
|
+
Message,
|
|
42
|
+
SendResult,
|
|
43
|
+
Support,
|
|
44
|
+
)
|
|
45
|
+
from correspond.ops import (
|
|
46
|
+
Editor,
|
|
47
|
+
Listener,
|
|
48
|
+
Reactor,
|
|
49
|
+
Reader,
|
|
50
|
+
Uploader,
|
|
51
|
+
Verifier,
|
|
52
|
+
Writer,
|
|
53
|
+
capabilities,
|
|
54
|
+
edit,
|
|
55
|
+
get_channel,
|
|
56
|
+
listen,
|
|
57
|
+
parse_ref,
|
|
58
|
+
react,
|
|
59
|
+
read,
|
|
60
|
+
send,
|
|
61
|
+
upload,
|
|
62
|
+
verify,
|
|
63
|
+
)
|
|
64
|
+
from correspond.registry import check_requirements, register_channel, unregister_channel
|
|
65
|
+
from correspond.registry import channels as channel_registry
|
|
66
|
+
from correspond.routing import RouteDecision, check_binding, metadata_rule, route
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"Account",
|
|
70
|
+
"Attachment",
|
|
71
|
+
"Authenticity",
|
|
72
|
+
"Capabilities",
|
|
73
|
+
"ChannelError",
|
|
74
|
+
"ChannelIdentity",
|
|
75
|
+
"ConversationRef",
|
|
76
|
+
"CorrespondError",
|
|
77
|
+
"Draft",
|
|
78
|
+
"Editor",
|
|
79
|
+
"Event",
|
|
80
|
+
"Grade",
|
|
81
|
+
"HistoryDepth",
|
|
82
|
+
"InvalidRef",
|
|
83
|
+
"Listener",
|
|
84
|
+
"Message",
|
|
85
|
+
"MissingRequirement",
|
|
86
|
+
"NotSupported",
|
|
87
|
+
"Reactor",
|
|
88
|
+
"Reader",
|
|
89
|
+
"RouteDecision",
|
|
90
|
+
"SendResult",
|
|
91
|
+
"Support",
|
|
92
|
+
"UnknownChannel",
|
|
93
|
+
"Uploader",
|
|
94
|
+
"Verifier",
|
|
95
|
+
"Writer",
|
|
96
|
+
"capabilities",
|
|
97
|
+
"channel_registry",
|
|
98
|
+
"check_binding",
|
|
99
|
+
"check_requirements",
|
|
100
|
+
"edit",
|
|
101
|
+
"get_channel",
|
|
102
|
+
"listen",
|
|
103
|
+
"metadata_rule",
|
|
104
|
+
"parse_ref",
|
|
105
|
+
"react",
|
|
106
|
+
"read",
|
|
107
|
+
"register_channel",
|
|
108
|
+
"route",
|
|
109
|
+
"send",
|
|
110
|
+
"unregister_channel",
|
|
111
|
+
"upload",
|
|
112
|
+
"verify",
|
|
113
|
+
]
|
correspond/__main__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
2
|
+
"""``correspond`` on the command line: ``cw`` over :data:`correspond.tools.TOOLS`.
|
|
3
|
+
|
|
4
|
+
``--json`` anywhere prints the tool's result as JSON instead of text. ``-`` as the text of
|
|
5
|
+
``send`` or ``edit`` reads it from stdin. A result with ``ok: false`` exits with status 1.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import cw
|
|
13
|
+
|
|
14
|
+
from correspond import tools
|
|
15
|
+
from correspond.render import render
|
|
16
|
+
|
|
17
|
+
_REF = {
|
|
18
|
+
"help": "a conversation reference, <channel>:<id> (e.g. github:octocat/hello-world#1)"
|
|
19
|
+
}
|
|
20
|
+
_DRY_RUN = {"help": "show the plan; contact nothing and change nothing"}
|
|
21
|
+
#: Per-parameter help for the command line (the tools' docstrings are the command help).
|
|
22
|
+
HELP = {
|
|
23
|
+
"requirements": {"channel": {"help": "a channel name, e.g. telegram"}},
|
|
24
|
+
"capabilities": {"channel": {"help": "a channel name, e.g. github"}},
|
|
25
|
+
"ref": {"ref": _REF},
|
|
26
|
+
"read": {
|
|
27
|
+
"ref": _REF,
|
|
28
|
+
"since": {"help": "only messages sent or edited at or after this ISO 8601 time"},
|
|
29
|
+
"limit": {"help": "keep the most recent N messages"},
|
|
30
|
+
},
|
|
31
|
+
"listen": {
|
|
32
|
+
"ref": _REF,
|
|
33
|
+
"limit": {"help": "at most N events"},
|
|
34
|
+
"peek": {"help": "show new events without moving the cursor"},
|
|
35
|
+
"data_dir": {"help": "where cursors are kept (default: the data root)"},
|
|
36
|
+
},
|
|
37
|
+
"send": {
|
|
38
|
+
"ref": _REF,
|
|
39
|
+
"text": {"help": "the message, or - to read it from stdin"},
|
|
40
|
+
"title": {"help": "a title; on github:owner/repo it opens an issue"},
|
|
41
|
+
"reply_to": {"help": "the id of the message this answers"},
|
|
42
|
+
"priority": {"help": "low, normal, high or urgent (channels with priorities)"},
|
|
43
|
+
"dry_run": _DRY_RUN,
|
|
44
|
+
},
|
|
45
|
+
"edit": {
|
|
46
|
+
"ref": _REF,
|
|
47
|
+
"message_id": {"help": "the message id, as read shows it"},
|
|
48
|
+
"text": {"help": "the new text, or - to read it from stdin"},
|
|
49
|
+
"dry_run": _DRY_RUN,
|
|
50
|
+
},
|
|
51
|
+
"react": {
|
|
52
|
+
"ref": _REF,
|
|
53
|
+
"message_id": {"help": "the message id, as read shows it"},
|
|
54
|
+
"reaction": {"help": "the reaction, e.g. +1 or eyes (see capabilities)"},
|
|
55
|
+
"dry_run": _DRY_RUN,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _egress(as_json):
|
|
61
|
+
def egress(result, *, out, err):
|
|
62
|
+
if as_json:
|
|
63
|
+
print(json.dumps(result, indent=2, ensure_ascii=False, default=str), file=out)
|
|
64
|
+
return 0 if result.get("ok", True) else 1
|
|
65
|
+
stdout, stderr, code = render(result)
|
|
66
|
+
if stderr:
|
|
67
|
+
print(stderr, file=err)
|
|
68
|
+
if stdout:
|
|
69
|
+
print(stdout, file=out)
|
|
70
|
+
return code
|
|
71
|
+
|
|
72
|
+
return egress
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv=None):
|
|
76
|
+
"""Run one ``correspond`` command."""
|
|
77
|
+
for stream in (
|
|
78
|
+
sys.stdout,
|
|
79
|
+
sys.stderr,
|
|
80
|
+
): # a cp1252 pipe must not crash on a message's characters
|
|
81
|
+
getattr(stream, "reconfigure", lambda **_: None)(errors="backslashreplace")
|
|
82
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
83
|
+
as_json = "--json" in argv
|
|
84
|
+
commands = {tool.__name__.replace("_", "-"): tool for tool in tools.TOOLS}
|
|
85
|
+
config = {
|
|
86
|
+
command: {param: dict(spec) for param, spec in params.items()}
|
|
87
|
+
for command, params in HELP.items()
|
|
88
|
+
}
|
|
89
|
+
for command in ("send", "edit"):
|
|
90
|
+
config[command]["text"]["codec"] = lambda text: (
|
|
91
|
+
sys.stdin.read() if text == "-" else text
|
|
92
|
+
)
|
|
93
|
+
raise SystemExit(
|
|
94
|
+
cw.dispatch(
|
|
95
|
+
commands,
|
|
96
|
+
[a for a in argv if a != "--json"],
|
|
97
|
+
prog="correspond",
|
|
98
|
+
convention=dataclasses.replace(cw.MODERN, default_in_help=False),
|
|
99
|
+
egress=_egress(as_json),
|
|
100
|
+
config=config,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
main()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""A small HTTP client over ``urllib``, shared by the HTTP adapters, with failures classified.
|
|
2
|
+
|
|
3
|
+
Adapters take it as ``http=``. The call shape is
|
|
4
|
+
``http(method, url, *, headers=None, body=None, timeout=DEFAULT_TIMEOUT_S) -> Response``:
|
|
5
|
+
an HTTP error status comes back as a ``Response`` like any other, and only "could not reach
|
|
6
|
+
the server" raises, as a ``ChannelError`` of kind ``network`` that names the host and never
|
|
7
|
+
the URL (a URL path can carry a token). Tests pass a scripted function of the same shape.
|
|
8
|
+
|
|
9
|
+
>>> classify(429, "slow down", {"retry-after": "3"}).retry_after
|
|
10
|
+
3.0
|
|
11
|
+
>>> classify(404, "gone").kind
|
|
12
|
+
'not_found'
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from email.utils import parsedate_to_datetime
|
|
24
|
+
from typing import Any
|
|
25
|
+
from urllib.parse import urlsplit
|
|
26
|
+
|
|
27
|
+
from correspond.errors import ChannelError
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"DEFAULT_TIMEOUT_S",
|
|
31
|
+
"Response",
|
|
32
|
+
"classify",
|
|
33
|
+
"retry_after_seconds",
|
|
34
|
+
"urllib_http",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
DEFAULT_TIMEOUT_S = 30
|
|
38
|
+
USER_AGENT = "correspond (+https://github.com/thorwhalen/correspond)"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Response:
|
|
43
|
+
"""An HTTP response: status, lower-cased headers, raw body."""
|
|
44
|
+
|
|
45
|
+
status: int
|
|
46
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
47
|
+
body: bytes = b""
|
|
48
|
+
|
|
49
|
+
def json(self) -> Any:
|
|
50
|
+
"""The body parsed as JSON, or ``None`` when it is not JSON."""
|
|
51
|
+
try:
|
|
52
|
+
return json.loads(self.body.decode("utf-8") or "null")
|
|
53
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _lowered(headers: Any) -> dict[str, str]:
|
|
58
|
+
return {k.lower(): v for k, v in headers.items()} if headers else {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def urllib_http(
|
|
62
|
+
method: str,
|
|
63
|
+
url: str,
|
|
64
|
+
*,
|
|
65
|
+
headers: Mapping[str, str] | None = None,
|
|
66
|
+
body: bytes | None = None,
|
|
67
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
68
|
+
) -> Response:
|
|
69
|
+
"""One request with the standard library."""
|
|
70
|
+
request = urllib.request.Request(
|
|
71
|
+
url,
|
|
72
|
+
data=body,
|
|
73
|
+
method=method,
|
|
74
|
+
headers={"User-Agent": USER_AGENT, **(headers or {})},
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
with urllib.request.urlopen(request, timeout=timeout) as reply:
|
|
78
|
+
return Response(reply.status, _lowered(reply.headers), reply.read())
|
|
79
|
+
except urllib.error.HTTPError as error:
|
|
80
|
+
try:
|
|
81
|
+
payload = error.read()
|
|
82
|
+
except OSError:
|
|
83
|
+
payload = b""
|
|
84
|
+
return Response(error.code, _lowered(error.headers), payload or b"")
|
|
85
|
+
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
|
86
|
+
reason = getattr(error, "reason", None) or type(error).__name__
|
|
87
|
+
raise ChannelError(
|
|
88
|
+
f"could not reach {urlsplit(url).hostname}: {reason}",
|
|
89
|
+
kind="network",
|
|
90
|
+
retryable=True,
|
|
91
|
+
) from None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def retry_after_seconds(headers: Mapping[str, str] | None) -> float | None:
|
|
95
|
+
"""``Retry-After`` in seconds (it may be a number or an HTTP date), or ``None``."""
|
|
96
|
+
raw = (headers or {}).get("retry-after")
|
|
97
|
+
if not raw:
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
return max(0.0, float(raw))
|
|
101
|
+
except ValueError:
|
|
102
|
+
try:
|
|
103
|
+
return max(0.0, parsedate_to_datetime(raw).timestamp() - time.time())
|
|
104
|
+
except (TypeError, ValueError):
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def classify(
|
|
109
|
+
status: int, message: str, headers: Mapping[str, str] | None = None
|
|
110
|
+
) -> ChannelError:
|
|
111
|
+
"""The :class:`~correspond.errors.ChannelError` an HTTP error status means."""
|
|
112
|
+
retry_after = retry_after_seconds(headers)
|
|
113
|
+
if status == 429:
|
|
114
|
+
return ChannelError(
|
|
115
|
+
message, kind="rate_limited", retryable=True, retry_after=retry_after
|
|
116
|
+
)
|
|
117
|
+
if status == 401:
|
|
118
|
+
return ChannelError(message, kind="auth")
|
|
119
|
+
if status == 403:
|
|
120
|
+
return ChannelError(message, kind="permission")
|
|
121
|
+
if status in (404, 410):
|
|
122
|
+
return ChannelError(message, kind="not_found")
|
|
123
|
+
if status in (408, 504):
|
|
124
|
+
return ChannelError(
|
|
125
|
+
message, kind="network", retryable=True, retry_after=retry_after
|
|
126
|
+
)
|
|
127
|
+
if 400 <= status < 500:
|
|
128
|
+
return ChannelError(message, kind="validation")
|
|
129
|
+
return ChannelError(
|
|
130
|
+
message, kind="unavailable", retryable=True, retry_after=retry_after
|
|
131
|
+
)
|