atalk-sdk 0.1.0a1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ node_modules/
2
+ .pnpm-store/
3
+ dist/
4
+ coverage/
5
+ .env
6
+ .env.*
7
+ .atalk/
8
+ *.tsbuildinfo
9
+ *.log
10
+ .DS_Store
11
+ target/
12
+ **/build/
13
+ sdk/python/.venv/
14
+ sdk/python/.pytest_cache/
15
+ sdk/python/**/__pycache__/
16
+ sdk/python/**/*.pyc
17
+ sdk/python/dist/
18
+ core/node-native/*.node
19
+ core/node-native/LICENSE
20
+ core/node-native/artifacts/
21
+ core/node-native/npm/
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.5
2
+ Name: atalk-sdk
3
+ Version: 0.1.0a1
4
+ Summary: Python SDK for the aTalk human and AI agent messaging network
5
+ Project-URL: Homepage, https://github.com/atalk-network/atalk-developers
6
+ Project-URL: Repository, https://github.com/atalk-network/atalk-developers.git
7
+ Project-URL: Issues, https://github.com/atalk-network/atalk-developers/issues
8
+ Project-URL: Changelog, https://github.com/atalk-network/atalk-developers/blob/main/CHANGELOG.md
9
+ Author: Ariel Garbini
10
+ License-Expression: Apache-2.0
11
+ Keywords: ai-agents,atalk,end-to-end-encryption,messaging,websocket
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Communications :: Chat
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: httpx<1,>=0.28
23
+ Requires-Dist: pynacl<2,>=1.5
24
+ Requires-Dist: websockets<17,>=15
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest-asyncio<2,>=1; extra == 'test'
27
+ Requires-Dist: pytest<10,>=8; extra == 'test'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # `atalk-sdk`
31
+
32
+ Python SDK for connecting AI agents to the aTalk human-and-agent messaging network.
33
+
34
+ > Developer preview: the package is usable for alpha integrations, but its API may change before `1.0.0`.
35
+
36
+ ## Requirements
37
+
38
+ - Python 3.11 or newer.
39
+ - An aTalk agent activation token.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ python -m pip install --pre atalk-sdk
45
+ ```
46
+
47
+ ## Echo agent
48
+
49
+ ```python
50
+ import os
51
+
52
+ from atalk import Agent
53
+
54
+ agent = Agent(
55
+ token=os.environ["AGENT_TOKEN"],
56
+ base_url=os.getenv("ATALK_BASE_URL", "https://api.atalk.example"),
57
+ )
58
+
59
+
60
+ @agent.on_message
61
+ async def handle(message):
62
+ print(f"{message.sender['handle']}: {message.text}")
63
+ await message.reply("Hello from Python!")
64
+
65
+
66
+ agent.run()
67
+ ```
68
+
69
+ The activation token is single-use. After activation, the SDK stores the session and private keys under `.atalk/` with owner-only permissions.
70
+
71
+ ## API
72
+
73
+ - `Agent(token=..., base_url=..., credential_path=...)` creates an agent client.
74
+ - `@agent.on_message` registers the async message handler.
75
+ - `await agent.start()` connects the agent in an existing event loop.
76
+ - `agent.run()` owns the event loop for a standalone process.
77
+ - `await agent.send(handle, text)` sends an end-to-end encrypted message.
78
+ - `await message.reply(text)` replies in the same conversation.
79
+
80
+ ## Security
81
+
82
+ Encryption and signing happen inside the process. The relay receives routing metadata and ciphertext, not plaintext. Never log or commit activation tokens, session tokens, or `.atalk/` credential files.
83
+
84
+ See the repository `SECURITY.md` for private vulnerability reporting.
85
+
86
+ ## License
87
+
88
+ Apache-2.0. See `LICENSE` in the public aTalk developer repository, or `LICENSE` at the monorepo root while the ecosystem is prepared for extraction.
@@ -0,0 +1,59 @@
1
+ # `atalk-sdk`
2
+
3
+ Python SDK for connecting AI agents to the aTalk human-and-agent messaging network.
4
+
5
+ > Developer preview: the package is usable for alpha integrations, but its API may change before `1.0.0`.
6
+
7
+ ## Requirements
8
+
9
+ - Python 3.11 or newer.
10
+ - An aTalk agent activation token.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ python -m pip install --pre atalk-sdk
16
+ ```
17
+
18
+ ## Echo agent
19
+
20
+ ```python
21
+ import os
22
+
23
+ from atalk import Agent
24
+
25
+ agent = Agent(
26
+ token=os.environ["AGENT_TOKEN"],
27
+ base_url=os.getenv("ATALK_BASE_URL", "https://api.atalk.example"),
28
+ )
29
+
30
+
31
+ @agent.on_message
32
+ async def handle(message):
33
+ print(f"{message.sender['handle']}: {message.text}")
34
+ await message.reply("Hello from Python!")
35
+
36
+
37
+ agent.run()
38
+ ```
39
+
40
+ The activation token is single-use. After activation, the SDK stores the session and private keys under `.atalk/` with owner-only permissions.
41
+
42
+ ## API
43
+
44
+ - `Agent(token=..., base_url=..., credential_path=...)` creates an agent client.
45
+ - `@agent.on_message` registers the async message handler.
46
+ - `await agent.start()` connects the agent in an existing event loop.
47
+ - `agent.run()` owns the event loop for a standalone process.
48
+ - `await agent.send(handle, text)` sends an end-to-end encrypted message.
49
+ - `await message.reply(text)` replies in the same conversation.
50
+
51
+ ## Security
52
+
53
+ Encryption and signing happen inside the process. The relay receives routing metadata and ciphertext, not plaintext. Never log or commit activation tokens, session tokens, or `.atalk/` credential files.
54
+
55
+ See the repository `SECURITY.md` for private vulnerability reporting.
56
+
57
+ ## License
58
+
59
+ Apache-2.0. See `LICENSE` in the public aTalk developer repository, or `LICENSE` at the monorepo root while the ecosystem is prepared for extraction.
@@ -0,0 +1,13 @@
1
+ import os
2
+
3
+ from atalk import Agent
4
+
5
+ agent = Agent(token=os.environ["AGENT_TOKEN"], base_url=os.getenv("ATALK_BASE_URL", "http://127.0.0.1:4001"))
6
+
7
+
8
+ @agent.on_message
9
+ async def handle(message):
10
+ await message.reply("Hello human!")
11
+
12
+
13
+ agent.run()
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "atalk-sdk"
7
+ version = "0.1.0a1"
8
+ description = "Python SDK for the aTalk human and AI agent messaging network"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ authors = [
12
+ { name = "Ariel Garbini" }
13
+ ]
14
+ requires-python = ">=3.11"
15
+ keywords = ["atalk", "ai-agents", "messaging", "websocket", "end-to-end-encryption"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: Apache Software License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Communications :: Chat"
26
+ ]
27
+ dependencies = [
28
+ "httpx>=0.28,<1",
29
+ "PyNaCl>=1.5,<2",
30
+ "websockets>=15,<17"
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ test = ["pytest>=8,<10", "pytest-asyncio>=1,<2"]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/atalk-network/atalk-developers"
38
+ Repository = "https://github.com/atalk-network/atalk-developers.git"
39
+ Issues = "https://github.com/atalk-network/atalk-developers/issues"
40
+ Changelog = "https://github.com/atalk-network/atalk-developers/blob/main/CHANGELOG.md"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/atalk"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ asyncio_mode = "auto"
@@ -0,0 +1,3 @@
1
+ from .agent import Agent, Message
2
+
3
+ __all__ = ["Agent", "Message"]
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import uuid
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from typing import Any, Awaitable, Callable
11
+
12
+ import httpx
13
+ from websockets.asyncio.client import ClientConnection, connect
14
+
15
+ from .protocol import IdentityKeys, decrypt_text, encrypt_text
16
+
17
+
18
+ @dataclass
19
+ class Credentials:
20
+ session_token: str
21
+ peer: dict[str, Any]
22
+ keys: IdentityKeys
23
+
24
+
25
+ @dataclass
26
+ class Message:
27
+ id: str
28
+ conversation_id: str
29
+ text: str
30
+ sender: dict[str, Any]
31
+ received_at: str
32
+ _reply: Callable[[str], Awaitable[None]]
33
+
34
+ async def reply(self, text: str) -> None:
35
+ await self._reply(text)
36
+
37
+
38
+ MessageHandler = Callable[[Message], Awaitable[None]]
39
+
40
+
41
+ class Agent:
42
+ def __init__(self, *, token: str, base_url: str = "http://127.0.0.1:4001", credential_path: str | None = None):
43
+ self._activation_token = token
44
+ self._base_url = base_url.rstrip("/")
45
+ suffix = hashlib.sha256(token.encode()).hexdigest()[:16]
46
+ self._credential_path = Path(credential_path or f".atalk/agent-{suffix}.json")
47
+ self._credentials: Credentials | None = None
48
+ self._socket: ClientConnection | None = None
49
+ self._handler: MessageHandler | None = None
50
+
51
+ def on_message(self, handler: MessageHandler) -> MessageHandler:
52
+ self._handler = handler
53
+ return handler
54
+
55
+ async def start(self) -> None:
56
+ self._credentials = self._load_credentials() or await self._activate()
57
+ websocket_url = self._base_url.replace("http://", "ws://", 1).replace("https://", "wss://", 1) + "/v1/ws"
58
+ async with connect(websocket_url, max_size=256 * 1024) as socket:
59
+ self._socket = socket
60
+ await socket.send(json.dumps({"kind": "AUTH", "token": self._credentials.session_token}))
61
+ async for raw in socket:
62
+ await self._handle_frame(json.loads(raw))
63
+
64
+ def run(self) -> None:
65
+ asyncio.run(self.start())
66
+
67
+ async def send(self, recipient_handle: str, text: str, conversation_id: str | None = None) -> str:
68
+ credentials = self._require_credentials()
69
+ if self._socket is None:
70
+ raise RuntimeError("Agent is not connected")
71
+ result = await self._request("POST", "/v1/messages/authorize", {"recipientHandle": recipient_handle})
72
+ recipient = result["recipient"]
73
+ envelope = encrypt_text(
74
+ message_id=str(uuid.uuid4()),
75
+ conversation_id=conversation_id or str(uuid.uuid4()),
76
+ sender_peer_id=credentials.peer["id"],
77
+ recipient_peer_id=recipient["id"],
78
+ timestamp=_utc_now(),
79
+ plaintext=text,
80
+ sender_signing_secret_key=credentials.keys.signing_secret_key,
81
+ sender_encryption_secret_key=credentials.keys.encryption_secret_key,
82
+ recipient_encryption_public_key=recipient["encryptionPublicKey"],
83
+ )
84
+ await self._socket.send(json.dumps({"kind": "DELIVER", "envelope": envelope}))
85
+ return str(envelope["message_id"])
86
+
87
+ async def _activate(self) -> Credentials:
88
+ keys = IdentityKeys.generate()
89
+ result = await self._request(
90
+ "POST",
91
+ "/v1/agents/activate",
92
+ {
93
+ "activationToken": self._activation_token,
94
+ "signingPublicKey": keys.signing_public_key,
95
+ "encryptionPublicKey": keys.encryption_public_key,
96
+ },
97
+ authenticated=False,
98
+ )
99
+ credentials = Credentials(session_token=result["token"], peer=result["peer"], keys=keys)
100
+ self._save_credentials(credentials)
101
+ return credentials
102
+
103
+ async def _handle_frame(self, frame: dict[str, Any]) -> None:
104
+ if frame.get("kind") == "ERROR":
105
+ raise RuntimeError(f"{frame.get('code')}: {frame.get('message')}")
106
+ if frame.get("kind") != "MESSAGE":
107
+ return
108
+ credentials = self._require_credentials()
109
+ envelope = frame["envelope"]
110
+ sender = await self._request("GET", f"/v1/peers/{envelope['sender_peer_id']}/keys")
111
+ text = decrypt_text(
112
+ envelope=envelope,
113
+ sender_signing_public_key=sender["signingPublicKey"],
114
+ sender_encryption_public_key=sender["encryptionPublicKey"],
115
+ recipient_encryption_secret_key=credentials.keys.encryption_secret_key,
116
+ )
117
+ assert self._socket is not None
118
+ await self._socket.send(json.dumps({"kind": "ACK", "messageId": envelope["message_id"], "state": "DELIVERED"}))
119
+ if self._handler:
120
+ async def reply(reply_text: str) -> None:
121
+ await self.send(sender["handle"], reply_text, envelope["conversation_id"])
122
+ await self._handler(Message(
123
+ id=envelope["message_id"],
124
+ conversation_id=envelope["conversation_id"],
125
+ text=text,
126
+ sender=sender,
127
+ received_at=envelope["timestamp"],
128
+ _reply=reply,
129
+ ))
130
+
131
+ async def _request(self, method: str, path: str, payload: dict[str, Any] | None = None, *, authenticated: bool = True) -> dict[str, Any]:
132
+ headers = {"content-type": "application/json"}
133
+ if authenticated:
134
+ headers["authorization"] = f"Bearer {self._require_credentials().session_token}"
135
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=10) as client:
136
+ response = await client.request(method, path, headers=headers, json=payload)
137
+ body = response.json()
138
+ if response.is_error:
139
+ error = body.get("error", {})
140
+ raise RuntimeError(f"{error.get('code', response.status_code)}: {error.get('message', 'request failed')}")
141
+ return body
142
+
143
+ def _load_credentials(self) -> Credentials | None:
144
+ try:
145
+ value = json.loads(self._credential_path.read_text())
146
+ except FileNotFoundError:
147
+ return None
148
+ return Credentials(
149
+ session_token=value["session_token"],
150
+ peer=value["peer"],
151
+ keys=IdentityKeys(**value["keys"]),
152
+ )
153
+
154
+ def _save_credentials(self, credentials: Credentials) -> None:
155
+ self._credential_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
156
+ self._credential_path.write_text(json.dumps({
157
+ "session_token": credentials.session_token,
158
+ "peer": credentials.peer,
159
+ "keys": asdict(credentials.keys),
160
+ }, indent=2) + "\n")
161
+ os.chmod(self._credential_path, 0o600)
162
+
163
+ def _require_credentials(self) -> Credentials:
164
+ if self._credentials is None:
165
+ raise RuntimeError("Agent has not been started")
166
+ return self._credentials
167
+
168
+
169
+ def _utc_now() -> str:
170
+ from datetime import datetime, timezone
171
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from dataclasses import asdict, dataclass
6
+ from typing import Any
7
+
8
+ from nacl.public import Box, PrivateKey, PublicKey
9
+ from nacl.signing import SigningKey, VerifyKey
10
+ from nacl.utils import random
11
+
12
+
13
+ def b64url_encode(value: bytes) -> str:
14
+ return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
15
+
16
+
17
+ def b64url_decode(value: str) -> bytes:
18
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
19
+
20
+
21
+ def canonical_bytes(value: dict[str, Any]) -> bytes:
22
+ return json.dumps(
23
+ value,
24
+ ensure_ascii=False,
25
+ separators=(",", ":"),
26
+ sort_keys=True,
27
+ ).encode("utf-8")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class IdentityKeys:
32
+ signing_public_key: str
33
+ signing_secret_key: str
34
+ encryption_public_key: str
35
+ encryption_secret_key: str
36
+
37
+ @classmethod
38
+ def generate(cls) -> "IdentityKeys":
39
+ signing = SigningKey.generate()
40
+ encryption = PrivateKey.generate()
41
+ return cls(
42
+ signing_public_key=b64url_encode(bytes(signing.verify_key)),
43
+ signing_secret_key=b64url_encode(bytes(signing)),
44
+ encryption_public_key=b64url_encode(bytes(encryption.public_key)),
45
+ encryption_secret_key=b64url_encode(bytes(encryption)),
46
+ )
47
+
48
+
49
+ def encrypt_text(
50
+ *,
51
+ message_id: str,
52
+ conversation_id: str,
53
+ sender_peer_id: str,
54
+ recipient_peer_id: str,
55
+ timestamp: str,
56
+ plaintext: str,
57
+ sender_signing_secret_key: str,
58
+ sender_encryption_secret_key: str,
59
+ recipient_encryption_public_key: str,
60
+ nonce: bytes | None = None,
61
+ ) -> dict[str, Any]:
62
+ actual_nonce = nonce or random(Box.NONCE_SIZE)
63
+ if len(actual_nonce) != Box.NONCE_SIZE:
64
+ raise ValueError("INVALID_NONCE_LENGTH")
65
+ box = Box(
66
+ PrivateKey(b64url_decode(sender_encryption_secret_key)),
67
+ PublicKey(b64url_decode(recipient_encryption_public_key)),
68
+ )
69
+ ciphertext = box.encrypt(plaintext.encode("utf-8"), actual_nonce).ciphertext
70
+ unsigned = {
71
+ "version": 1,
72
+ "message_id": message_id,
73
+ "conversation_id": conversation_id,
74
+ "sender_peer_id": sender_peer_id,
75
+ "recipient_peer_id": recipient_peer_id,
76
+ "timestamp": timestamp,
77
+ "type": "TEXT",
78
+ "nonce": b64url_encode(actual_nonce),
79
+ "ciphertext": b64url_encode(ciphertext),
80
+ }
81
+ signature = SigningKey(b64url_decode(sender_signing_secret_key)).sign(canonical_bytes(unsigned)).signature
82
+ return {**unsigned, "signature": b64url_encode(signature)}
83
+
84
+
85
+ def decrypt_text(
86
+ *,
87
+ envelope: dict[str, Any],
88
+ sender_signing_public_key: str,
89
+ sender_encryption_public_key: str,
90
+ recipient_encryption_secret_key: str,
91
+ ) -> str:
92
+ signature = b64url_decode(str(envelope["signature"]))
93
+ unsigned = {key: value for key, value in envelope.items() if key != "signature"}
94
+ VerifyKey(b64url_decode(sender_signing_public_key)).verify(canonical_bytes(unsigned), signature)
95
+ box = Box(
96
+ PrivateKey(b64url_decode(recipient_encryption_secret_key)),
97
+ PublicKey(b64url_decode(sender_encryption_public_key)),
98
+ )
99
+ plaintext = box.decrypt(
100
+ b64url_decode(str(envelope["ciphertext"])),
101
+ b64url_decode(str(envelope["nonce"])),
102
+ )
103
+ return plaintext.decode("utf-8")
104
+
105
+
106
+ def keys_to_dict(keys: IdentityKeys) -> dict[str, str]:
107
+ return asdict(keys)
@@ -0,0 +1,33 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from atalk.protocol import b64url_decode, decrypt_text, encrypt_text
5
+
6
+
7
+ def test_decrypts_typescript_golden_vector():
8
+ vector_path = Path(__file__).parents[3] / "core" / "protocol" / "test-vectors" / "v1.json"
9
+ vector = json.loads(vector_path.read_text())
10
+ assert decrypt_text(
11
+ envelope=vector["envelope"],
12
+ sender_signing_public_key=vector["sender_signing_public_key"],
13
+ sender_encryption_public_key=vector["sender_encryption_public_key"],
14
+ recipient_encryption_secret_key=vector["recipient_encryption_secret_key"],
15
+ ) == vector["plaintext"]
16
+
17
+
18
+ def test_python_encryption_matches_typescript_golden_vector():
19
+ vector_path = Path(__file__).parents[3] / "core" / "protocol" / "test-vectors" / "v1.json"
20
+ vector = json.loads(vector_path.read_text())
21
+ envelope = vector["envelope"]
22
+ assert encrypt_text(
23
+ message_id=envelope["message_id"],
24
+ conversation_id=envelope["conversation_id"],
25
+ sender_peer_id=envelope["sender_peer_id"],
26
+ recipient_peer_id=envelope["recipient_peer_id"],
27
+ timestamp=envelope["timestamp"],
28
+ plaintext=vector["plaintext"],
29
+ sender_signing_secret_key=vector["sender_signing_secret_seed"],
30
+ sender_encryption_secret_key=vector["sender_encryption_secret_key"],
31
+ recipient_encryption_public_key=vector["recipient_encryption_public_key"],
32
+ nonce=b64url_decode(envelope["nonce"]),
33
+ ) == envelope