maverick-channels 0.1.2__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.
Files changed (30) hide show
  1. maverick_channels-0.1.2/PKG-INFO +126 -0
  2. maverick_channels-0.1.2/README.md +88 -0
  3. maverick_channels-0.1.2/maverick_channels/__init__.py +24 -0
  4. maverick_channels-0.1.2/maverick_channels/base.py +87 -0
  5. maverick_channels-0.1.2/maverick_channels/bluesky.py +245 -0
  6. maverick_channels-0.1.2/maverick_channels/cli.py +38 -0
  7. maverick_channels-0.1.2/maverick_channels/discord.py +105 -0
  8. maverick_channels-0.1.2/maverick_channels/email.py +159 -0
  9. maverick_channels-0.1.2/maverick_channels/imessage.py +135 -0
  10. maverick_channels-0.1.2/maverick_channels/mastodon.py +229 -0
  11. maverick_channels-0.1.2/maverick_channels/matrix.py +87 -0
  12. maverick_channels-0.1.2/maverick_channels/signal.py +109 -0
  13. maverick_channels-0.1.2/maverick_channels/slack.py +87 -0
  14. maverick_channels-0.1.2/maverick_channels/sms.py +142 -0
  15. maverick_channels-0.1.2/maverick_channels/telegram.py +121 -0
  16. maverick_channels-0.1.2/maverick_channels/voice.py +192 -0
  17. maverick_channels-0.1.2/maverick_channels/whatsapp.py +148 -0
  18. maverick_channels-0.1.2/maverick_channels.egg-info/PKG-INFO +126 -0
  19. maverick_channels-0.1.2/maverick_channels.egg-info/SOURCES.txt +28 -0
  20. maverick_channels-0.1.2/maverick_channels.egg-info/dependency_links.txt +1 -0
  21. maverick_channels-0.1.2/maverick_channels.egg-info/requires.txt +35 -0
  22. maverick_channels-0.1.2/maverick_channels.egg-info/top_level.txt +1 -0
  23. maverick_channels-0.1.2/pyproject.toml +40 -0
  24. maverick_channels-0.1.2/setup.cfg +4 -0
  25. maverick_channels-0.1.2/tests/test_base.py +24 -0
  26. maverick_channels-0.1.2/tests/test_cli_channel.py +85 -0
  27. maverick_channels-0.1.2/tests/test_hardening_round.py +59 -0
  28. maverick_channels-0.1.2/tests/test_imessage_safe_send.py +64 -0
  29. maverick_channels-0.1.2/tests/test_sms_signature.py +57 -0
  30. maverick_channels-0.1.2/tests/test_voice_channel.py +99 -0
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: maverick-channels
3
+ Version: 0.1.2
4
+ Summary: Channel adapters for Maverick (Telegram, Discord, Slack, Signal, ...)
5
+ Author: cdayAI
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/cdayAI/maverick
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: maverick-agent>=0.1
11
+ Provides-Extra: telegram
12
+ Requires-Dist: python-telegram-bot>=21.0; extra == "telegram"
13
+ Provides-Extra: discord
14
+ Requires-Dist: discord.py>=2.4; extra == "discord"
15
+ Provides-Extra: slack
16
+ Requires-Dist: slack_sdk>=3.27; extra == "slack"
17
+ Provides-Extra: matrix
18
+ Requires-Dist: matrix-nio>=0.24; extra == "matrix"
19
+ Provides-Extra: whatsapp
20
+ Requires-Dist: fastapi>=0.110; extra == "whatsapp"
21
+ Requires-Dist: uvicorn>=0.27; extra == "whatsapp"
22
+ Requires-Dist: twilio>=9.0; extra == "whatsapp"
23
+ Requires-Dist: python-multipart>=0.0.9; extra == "whatsapp"
24
+ Provides-Extra: sms
25
+ Requires-Dist: fastapi>=0.110; extra == "sms"
26
+ Requires-Dist: uvicorn>=0.27; extra == "sms"
27
+ Requires-Dist: twilio>=9.0; extra == "sms"
28
+ Requires-Dist: python-multipart>=0.0.9; extra == "sms"
29
+ Provides-Extra: all
30
+ Requires-Dist: python-telegram-bot>=21.0; extra == "all"
31
+ Requires-Dist: discord.py>=2.4; extra == "all"
32
+ Requires-Dist: slack_sdk>=3.27; extra == "all"
33
+ Requires-Dist: matrix-nio>=0.24; extra == "all"
34
+ Requires-Dist: fastapi>=0.110; extra == "all"
35
+ Requires-Dist: uvicorn>=0.27; extra == "all"
36
+ Requires-Dist: twilio>=9.0; extra == "all"
37
+ Requires-Dist: python-multipart>=0.0.9; extra == "all"
38
+
39
+ # maverick-channels
40
+
41
+ Channel adapters for Maverick. A channel normalizes incoming messages
42
+ from any platform into a shared `{user_id, text, attachments}` shape,
43
+ hands it to the orchestrator, and routes the response back.
44
+
45
+ This is how phone-companion mode works: Maverick itself runs on your
46
+ Desktop or VPS (`maverick serve`), and any of these channels gives your
47
+ phone (or any other client) a frontend.
48
+
49
+ ## Channels
50
+
51
+ | Channel | Status | Install | Notes |
52
+ |----------|----------|------------------------------------------|-------|
53
+ | CLI | ready | bundled | stdin/stdout, used by `maverick start` |
54
+ | Telegram | ready | `pip install '.[telegram]'` | Long-poll, no public endpoint needed |
55
+ | Discord | ready | `pip install '.[discord]'` | Gateway WebSocket |
56
+ | Slack | ready | `pip install '.[slack]'` | Socket Mode |
57
+ | Signal | ready | bundled (needs `signal-cli` on PATH) | JSON-RPC over subprocess |
58
+ | Email | ready | bundled | IMAP poll + SMTP send (stdlib) |
59
+ | Matrix | ready | `pip install '.[matrix]'` | Federated, end-to-end encryptable |
60
+ | WhatsApp | scaffold | `pip install '.[whatsapp]'` | Twilio webhook — needs public HTTPS |
61
+ | SMS | scaffold | `pip install '.[sms]'` | Twilio webhook — needs public HTTPS |
62
+ | iMessage | scaffold | bundled | macOS only, needs Full Disk Access |
63
+
64
+ or everything at once:
65
+
66
+ ```bash
67
+ pip install 'maverick-channels[all]'
68
+ ```
69
+
70
+ ## The interface
71
+
72
+ Every channel implements:
73
+
74
+ ```python
75
+ class Channel:
76
+ async def start(self) -> None: ...
77
+ async def send(self, user_id: str, text: str) -> None: ...
78
+ async def stop(self) -> None: ...
79
+ ```
80
+
81
+ And dispatches `IncomingMessage(user_id, text, attachments)` to a single
82
+ handler the wizard wires up.
83
+
84
+ ## Wiring channels
85
+
86
+ In `~/.maverick/config.toml`:
87
+
88
+ ```toml
89
+ [channels.telegram]
90
+ enabled = true
91
+ bot_token = "${TELEGRAM_BOT_TOKEN}"
92
+ allowed_user_ids = ["123456789"]
93
+ # optional alternative: allowed_chat_ids = ["-1001234567890"]
94
+
95
+ [channels.discord]
96
+ enabled = true
97
+ bot_token = "${DISCORD_BOT_TOKEN}"
98
+
99
+ [channels.slack]
100
+ enabled = false
101
+ app_token = "${SLACK_APP_TOKEN}"
102
+ bot_token = "${SLACK_BOT_TOKEN}"
103
+
104
+ [channels.signal]
105
+ enabled = false
106
+ phone_number = "+12345550199"
107
+
108
+ [channels.email]
109
+ enabled = false
110
+ imap_host = "imap.gmail.com"
111
+ imap_user = "${EMAIL_USER}"
112
+ imap_password = "${EMAIL_APP_PASSWORD}"
113
+ smtp_host = "smtp.gmail.com"
114
+ smtp_port = 465
115
+ smtp_user = "${EMAIL_USER}"
116
+ smtp_password = "${EMAIL_APP_PASSWORD}"
117
+ ```
118
+
119
+ Then run:
120
+
121
+ ```bash
122
+ maverick serve
123
+ ```
124
+
125
+ Multiple channels can be enabled simultaneously — each runs in its own
126
+ async task.
@@ -0,0 +1,88 @@
1
+ # maverick-channels
2
+
3
+ Channel adapters for Maverick. A channel normalizes incoming messages
4
+ from any platform into a shared `{user_id, text, attachments}` shape,
5
+ hands it to the orchestrator, and routes the response back.
6
+
7
+ This is how phone-companion mode works: Maverick itself runs on your
8
+ Desktop or VPS (`maverick serve`), and any of these channels gives your
9
+ phone (or any other client) a frontend.
10
+
11
+ ## Channels
12
+
13
+ | Channel | Status | Install | Notes |
14
+ |----------|----------|------------------------------------------|-------|
15
+ | CLI | ready | bundled | stdin/stdout, used by `maverick start` |
16
+ | Telegram | ready | `pip install '.[telegram]'` | Long-poll, no public endpoint needed |
17
+ | Discord | ready | `pip install '.[discord]'` | Gateway WebSocket |
18
+ | Slack | ready | `pip install '.[slack]'` | Socket Mode |
19
+ | Signal | ready | bundled (needs `signal-cli` on PATH) | JSON-RPC over subprocess |
20
+ | Email | ready | bundled | IMAP poll + SMTP send (stdlib) |
21
+ | Matrix | ready | `pip install '.[matrix]'` | Federated, end-to-end encryptable |
22
+ | WhatsApp | scaffold | `pip install '.[whatsapp]'` | Twilio webhook — needs public HTTPS |
23
+ | SMS | scaffold | `pip install '.[sms]'` | Twilio webhook — needs public HTTPS |
24
+ | iMessage | scaffold | bundled | macOS only, needs Full Disk Access |
25
+
26
+ or everything at once:
27
+
28
+ ```bash
29
+ pip install 'maverick-channels[all]'
30
+ ```
31
+
32
+ ## The interface
33
+
34
+ Every channel implements:
35
+
36
+ ```python
37
+ class Channel:
38
+ async def start(self) -> None: ...
39
+ async def send(self, user_id: str, text: str) -> None: ...
40
+ async def stop(self) -> None: ...
41
+ ```
42
+
43
+ And dispatches `IncomingMessage(user_id, text, attachments)` to a single
44
+ handler the wizard wires up.
45
+
46
+ ## Wiring channels
47
+
48
+ In `~/.maverick/config.toml`:
49
+
50
+ ```toml
51
+ [channels.telegram]
52
+ enabled = true
53
+ bot_token = "${TELEGRAM_BOT_TOKEN}"
54
+ allowed_user_ids = ["123456789"]
55
+ # optional alternative: allowed_chat_ids = ["-1001234567890"]
56
+
57
+ [channels.discord]
58
+ enabled = true
59
+ bot_token = "${DISCORD_BOT_TOKEN}"
60
+
61
+ [channels.slack]
62
+ enabled = false
63
+ app_token = "${SLACK_APP_TOKEN}"
64
+ bot_token = "${SLACK_BOT_TOKEN}"
65
+
66
+ [channels.signal]
67
+ enabled = false
68
+ phone_number = "+12345550199"
69
+
70
+ [channels.email]
71
+ enabled = false
72
+ imap_host = "imap.gmail.com"
73
+ imap_user = "${EMAIL_USER}"
74
+ imap_password = "${EMAIL_APP_PASSWORD}"
75
+ smtp_host = "smtp.gmail.com"
76
+ smtp_port = 465
77
+ smtp_user = "${EMAIL_USER}"
78
+ smtp_password = "${EMAIL_APP_PASSWORD}"
79
+ ```
80
+
81
+ Then run:
82
+
83
+ ```bash
84
+ maverick serve
85
+ ```
86
+
87
+ Multiple channels can be enabled simultaneously — each runs in its own
88
+ async task.
@@ -0,0 +1,24 @@
1
+ """Channel adapters for Maverick.
2
+
3
+ A channel normalizes incoming messages from any platform into a shared
4
+ ``IncomingMessage`` shape, hands it to the orchestrator, and routes the
5
+ response back. This is the surface Maverick uses to power phone-companion
6
+ mode — the agent itself runs on Desktop or VPS, and channels give a
7
+ phone (or any other client) a way to talk to it.
8
+
9
+ Available channels (status as of v0.1):
10
+ - cli (ready)
11
+ - telegram (ready)
12
+ - discord (ready)
13
+ - slack (ready)
14
+ - signal (ready, requires signal-cli on PATH)
15
+ - email (ready, stdlib only)
16
+ - matrix (ready, requires matrix-nio)
17
+ - whatsapp (scaffold, requires Twilio + public webhook)
18
+ - sms (scaffold, requires Twilio + public webhook)
19
+ - imessage (macOS only)
20
+ """
21
+ from .base import Channel, IncomingMessage, Handler
22
+
23
+ __version__ = "0.1.2"
24
+ __all__ = ["Channel", "IncomingMessage", "Handler"]
@@ -0,0 +1,87 @@
1
+ """Channel interface.
2
+
3
+ Normalize every platform (CLI, Telegram, iMessage, ...) to the same shape
4
+ so the agent loop doesn't have to care where a message came from.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass, field
11
+ from typing import Awaitable, Callable
12
+
13
+
14
+ def normalize_allowlist(values, env_name: str) -> set:
15
+ """Build an access allowlist from an explicit arg or a comma-separated
16
+ env var (e.g. ``DISCORD_ALLOWED_USER_IDS``). Shared so every channel
17
+ enforces access the same way instead of each rolling its own."""
18
+ if values is not None:
19
+ return {str(v).strip() for v in values if str(v).strip()}
20
+ raw = os.environ.get(env_name, "")
21
+ return {item.strip() for item in raw.split(",") if item.strip()}
22
+
23
+
24
+ def is_allowed(user_id, allowlist) -> bool:
25
+ """True only if ``user_id`` is an explicit allowlist member. A missing
26
+ id or the ``"anonymous"`` fallback NEVER passes — treat unknown as deny
27
+ so a channel that can't identify the sender can't be driven by anyone."""
28
+ if not allowlist:
29
+ return False
30
+ uid = str(user_id or "").strip()
31
+ if not uid or uid == "anonymous":
32
+ return False
33
+ return uid in allowlist
34
+
35
+
36
+ def _max_inbound_chars() -> int:
37
+ """Cap on inbound text fed to the swarm. A single oversized inbound
38
+ message (a 200KB email, an attacker-crafted mention) would otherwise
39
+ drive an uncapped-context, uncapped-cost agent run. Override with
40
+ MAVERICK_MAX_INBOUND_CHARS; 0 disables the cap."""
41
+ try:
42
+ return int(os.environ.get("MAVERICK_MAX_INBOUND_CHARS", "100000"))
43
+ except ValueError:
44
+ return 100000
45
+
46
+
47
+ @dataclass
48
+ class IncomingMessage:
49
+ user_id: str
50
+ text: str
51
+ attachments: list[dict] = field(default_factory=list)
52
+ channel: str = ""
53
+ raw: object = None
54
+
55
+ def __post_init__(self) -> None:
56
+ cap = _max_inbound_chars()
57
+ if cap and isinstance(self.text, str) and len(self.text) > cap:
58
+ self.text = self.text[:cap] + "\n\n[...truncated by Maverick inbound cap]"
59
+
60
+
61
+ Handler = Callable[[IncomingMessage], Awaitable[str]]
62
+ """A handler takes a normalized message and returns the agent's reply."""
63
+
64
+
65
+ class Channel(ABC):
66
+ """Abstract channel adapter.
67
+
68
+ Lifecycle:
69
+ - ``start()`` blocks (or runs in background) accepting messages.
70
+ - For each message it dispatches to the registered ``Handler``.
71
+ - ``send(user_id, text)`` pushes a reply back to that user.
72
+ - ``stop()`` cleans up.
73
+ """
74
+
75
+ name: str
76
+
77
+ def __init__(self, handler: Handler):
78
+ self.handler = handler
79
+
80
+ @abstractmethod
81
+ async def start(self) -> None: ...
82
+
83
+ @abstractmethod
84
+ async def send(self, user_id: str, text: str) -> None: ...
85
+
86
+ @abstractmethod
87
+ async def stop(self) -> None: ...
@@ -0,0 +1,245 @@
1
+ """Bluesky / AT Protocol channel adapter.
2
+
3
+ Polls the user's notifications timeline for mentions + DMs, dispatches
4
+ each as an IncomingMessage. Replies go back via the AT Proto REST API.
5
+
6
+ Auth: env vars BLUESKY_HANDLE + BLUESKY_PASSWORD. The 'password' is an
7
+ app password generated at bsky.app -> Settings -> App Passwords; never
8
+ the account password.
9
+
10
+ Heavy deps deferred to import time; the optional install is
11
+ ``pip install 'maverick-channels[bluesky]'`` which pulls in httpx
12
+ (already a transitive of openai-compat providers).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ import os
19
+ from typing import Optional, Set
20
+
21
+ from .base import Channel, Handler, IncomingMessage
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ _API_BASE = "https://bsky.social/xrpc"
27
+ _POLL_INTERVAL_SEC = 30.0
28
+
29
+
30
+ class BlueskyChannel(Channel):
31
+ """Bluesky AT Proto channel.
32
+
33
+ Reuses the same Handler / IncomingMessage shape as the other
34
+ channel adapters. Polls notifications every 30s; on a `mention`
35
+ or `reply` event, dispatches to the handler and posts the reply
36
+ as a thread reply.
37
+ """
38
+
39
+ name = "bluesky"
40
+
41
+ def __init__(
42
+ self,
43
+ handler: Handler,
44
+ *,
45
+ handle: Optional[str] = None,
46
+ password: Optional[str] = None,
47
+ allowed_user_ids: Optional[set[str]] = None,
48
+ poll_interval: float = _POLL_INTERVAL_SEC,
49
+ ):
50
+ super().__init__(handler)
51
+ self.handle = handle or os.environ.get("BLUESKY_HANDLE", "")
52
+ self.password = password or os.environ.get("BLUESKY_PASSWORD", "")
53
+ self.allowed_user_ids = self._normalize_allowlist(
54
+ allowed_user_ids,
55
+ env_name="BLUESKY_ALLOWED_USER_IDS",
56
+ )
57
+ if not self.allowed_user_ids:
58
+ raise ValueError(
59
+ "Set BLUESKY_ALLOWED_USER_IDS to restrict access"
60
+ )
61
+ self.poll_interval = poll_interval
62
+ self._session: dict = {}
63
+ self._last_seen_indexed_at: Optional[str] = None
64
+ self._running = False
65
+ self._stop_event = asyncio.Event()
66
+
67
+ @staticmethod
68
+ def _normalize_allowlist(values: Optional[set[str]], env_name: str) -> Set[str]:
69
+ if values is not None:
70
+ return {str(v).strip() for v in values if str(v).strip()}
71
+ raw = os.environ.get(env_name, "")
72
+ return {item.strip() for item in raw.split(",") if item.strip()}
73
+
74
+ async def _ensure_session(self) -> dict:
75
+ if self._session.get("accessJwt"):
76
+ return self._session
77
+ try:
78
+ import httpx
79
+ except ImportError as e:
80
+ raise RuntimeError(
81
+ "httpx not installed. Run: pip install 'maverick-channels[bluesky]'"
82
+ ) from e
83
+ if not self.handle or not self.password:
84
+ raise RuntimeError(
85
+ "Bluesky channel requires BLUESKY_HANDLE and BLUESKY_PASSWORD."
86
+ )
87
+ async with httpx.AsyncClient(timeout=30.0) as client:
88
+ resp = await client.post(
89
+ f"{_API_BASE}/com.atproto.server.createSession",
90
+ json={"identifier": self.handle, "password": self.password},
91
+ )
92
+ resp.raise_for_status()
93
+ self._session = resp.json()
94
+ return self._session
95
+
96
+ async def _poll_once(self) -> list[dict]:
97
+ """Fetch any new notifications since last poll."""
98
+ sess = await self._ensure_session()
99
+ try:
100
+ import httpx
101
+ except ImportError:
102
+ return []
103
+ async with httpx.AsyncClient(timeout=30.0) as client:
104
+ resp = await client.get(
105
+ f"{_API_BASE}/app.bsky.notification.listNotifications",
106
+ headers={"Authorization": f"Bearer {sess['accessJwt']}"},
107
+ params={"limit": 50},
108
+ )
109
+ if resp.status_code == 401:
110
+ # Session expired; force a re-login on next call.
111
+ self._session = {}
112
+ return []
113
+ resp.raise_for_status()
114
+ notifs = (resp.json() or {}).get("notifications") or []
115
+ # Filter: only new mentions / replies / DMs.
116
+ new: list[dict] = []
117
+ for n in notifs:
118
+ reason = n.get("reason")
119
+ if reason not in ("mention", "reply"):
120
+ continue
121
+ ts = n.get("indexedAt", "")
122
+ if self._last_seen_indexed_at and ts <= self._last_seen_indexed_at:
123
+ continue
124
+ new.append(n)
125
+ if new:
126
+ self._last_seen_indexed_at = max(n.get("indexedAt", "") for n in new)
127
+ return new
128
+
129
+ async def _dispatch(self, notif: dict) -> None:
130
+ record = notif.get("record") or {}
131
+ text = record.get("text", "")
132
+ author = notif.get("author") or {}
133
+ user_id = author.get("did") or author.get("handle") or "anonymous"
134
+ if user_id not in self.allowed_user_ids:
135
+ log.warning("unauthorized bluesky access: user_id=%s", user_id)
136
+ return
137
+ msg = IncomingMessage(
138
+ user_id=user_id, text=text,
139
+ channel=self.name, raw=notif,
140
+ )
141
+ try:
142
+ reply = await self.handler(msg)
143
+ except Exception as e:
144
+ log.exception("bluesky handler raised: %s", e)
145
+ return
146
+ if reply:
147
+ await self._reply(notif, reply)
148
+
149
+ async def _reply(self, parent_notif: dict, text: str) -> None:
150
+ """Post a reply in-thread to a notification."""
151
+ sess = await self._ensure_session()
152
+ try:
153
+ import httpx
154
+ except ImportError:
155
+ return
156
+ record = parent_notif.get("record") or {}
157
+ reply_root = record.get("reply", {}).get("root") or {
158
+ "uri": parent_notif.get("uri"),
159
+ "cid": parent_notif.get("cid"),
160
+ }
161
+ body = {
162
+ "repo": sess.get("did"),
163
+ "collection": "app.bsky.feed.post",
164
+ "record": {
165
+ "$type": "app.bsky.feed.post",
166
+ "text": text[:300], # 300-char limit
167
+ "createdAt": __import__("datetime").datetime.utcnow().isoformat() + "Z",
168
+ "reply": {
169
+ "root": reply_root,
170
+ "parent": {
171
+ "uri": parent_notif.get("uri"),
172
+ "cid": parent_notif.get("cid"),
173
+ },
174
+ },
175
+ },
176
+ }
177
+ async with httpx.AsyncClient(timeout=30.0) as client:
178
+ resp = await client.post(
179
+ f"{_API_BASE}/com.atproto.repo.createRecord",
180
+ headers={"Authorization": f"Bearer {sess['accessJwt']}"},
181
+ json=body,
182
+ )
183
+ if resp.status_code >= 400:
184
+ log.warning("bluesky post failed (%d): %s", resp.status_code, resp.text[:200])
185
+
186
+ async def start(self) -> None:
187
+ self._running = True
188
+ await self._ensure_session()
189
+ # Seed the cursor to "now" so the first poll only dispatches
190
+ # notifications that arrive AFTER startup. Without this, a cold
191
+ # start (or any restart) re-runs the agent swarm on the last 50
192
+ # mentions in history — duplicate replies + real LLM spend.
193
+ if self._last_seen_indexed_at is None:
194
+ import datetime as _dt
195
+ self._last_seen_indexed_at = (
196
+ _dt.datetime.now(_dt.timezone.utc)
197
+ .strftime("%Y-%m-%dT%H:%M:%S.%fZ")
198
+ )
199
+ log.info("Bluesky channel started (handle=%s)", self.handle)
200
+ try:
201
+ while not self._stop_event.is_set():
202
+ try:
203
+ notifs = await self._poll_once()
204
+ except Exception as e:
205
+ log.warning("bluesky poll failed: %s", e)
206
+ notifs = []
207
+ for n in notifs:
208
+ await self._dispatch(n)
209
+ try:
210
+ await asyncio.wait_for(
211
+ self._stop_event.wait(), timeout=self.poll_interval,
212
+ )
213
+ except asyncio.TimeoutError:
214
+ pass
215
+ finally:
216
+ self._running = False
217
+
218
+ async def send(self, user_id: str, text: str) -> None:
219
+ """Send a stand-alone message to a user (not in-thread)."""
220
+ # Bluesky doesn't have proper DMs in the public API yet;
221
+ # this falls back to a top-level post mentioning the user.
222
+ sess = await self._ensure_session()
223
+ try:
224
+ import httpx
225
+ except ImportError:
226
+ return
227
+ body = {
228
+ "repo": sess.get("did"),
229
+ "collection": "app.bsky.feed.post",
230
+ "record": {
231
+ "$type": "app.bsky.feed.post",
232
+ "text": f"@{user_id}: {text[:280]}",
233
+ "createdAt": __import__("datetime").datetime.utcnow().isoformat() + "Z",
234
+ },
235
+ }
236
+ async with httpx.AsyncClient(timeout=30.0) as client:
237
+ await client.post(
238
+ f"{_API_BASE}/com.atproto.repo.createRecord",
239
+ headers={"Authorization": f"Bearer {sess['accessJwt']}"},
240
+ json=body,
241
+ )
242
+
243
+ async def stop(self) -> None:
244
+ self._stop_event.set()
245
+ self._running = False
@@ -0,0 +1,38 @@
1
+ """Stdin/stdout channel.
2
+
3
+ The default channel — reads lines from stdin, dispatches them to the
4
+ handler, prints replies to stdout. Used by ``maverick start`` directly
5
+ and by tests.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import sys
11
+
12
+ from .base import Channel, IncomingMessage
13
+
14
+
15
+ class CLIChannel(Channel):
16
+ name = "cli"
17
+
18
+ async def start(self) -> None:
19
+ loop = asyncio.get_event_loop()
20
+ while True:
21
+ line = await loop.run_in_executor(None, sys.stdin.readline)
22
+ if not line:
23
+ return
24
+ text = line.rstrip("\n")
25
+ if not text:
26
+ continue
27
+ msg = IncomingMessage(user_id="local", text=text, channel="cli")
28
+ try:
29
+ reply = await self.handler(msg)
30
+ except Exception as e: # one bad run must not kill the session
31
+ reply = f"Sorry, I ran into an error: {e}"
32
+ await self.send("local", reply)
33
+
34
+ async def send(self, user_id: str, text: str) -> None:
35
+ print(text, flush=True)
36
+
37
+ async def stop(self) -> None:
38
+ pass