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.
- maverick_channels-0.1.2/PKG-INFO +126 -0
- maverick_channels-0.1.2/README.md +88 -0
- maverick_channels-0.1.2/maverick_channels/__init__.py +24 -0
- maverick_channels-0.1.2/maverick_channels/base.py +87 -0
- maverick_channels-0.1.2/maverick_channels/bluesky.py +245 -0
- maverick_channels-0.1.2/maverick_channels/cli.py +38 -0
- maverick_channels-0.1.2/maverick_channels/discord.py +105 -0
- maverick_channels-0.1.2/maverick_channels/email.py +159 -0
- maverick_channels-0.1.2/maverick_channels/imessage.py +135 -0
- maverick_channels-0.1.2/maverick_channels/mastodon.py +229 -0
- maverick_channels-0.1.2/maverick_channels/matrix.py +87 -0
- maverick_channels-0.1.2/maverick_channels/signal.py +109 -0
- maverick_channels-0.1.2/maverick_channels/slack.py +87 -0
- maverick_channels-0.1.2/maverick_channels/sms.py +142 -0
- maverick_channels-0.1.2/maverick_channels/telegram.py +121 -0
- maverick_channels-0.1.2/maverick_channels/voice.py +192 -0
- maverick_channels-0.1.2/maverick_channels/whatsapp.py +148 -0
- maverick_channels-0.1.2/maverick_channels.egg-info/PKG-INFO +126 -0
- maverick_channels-0.1.2/maverick_channels.egg-info/SOURCES.txt +28 -0
- maverick_channels-0.1.2/maverick_channels.egg-info/dependency_links.txt +1 -0
- maverick_channels-0.1.2/maverick_channels.egg-info/requires.txt +35 -0
- maverick_channels-0.1.2/maverick_channels.egg-info/top_level.txt +1 -0
- maverick_channels-0.1.2/pyproject.toml +40 -0
- maverick_channels-0.1.2/setup.cfg +4 -0
- maverick_channels-0.1.2/tests/test_base.py +24 -0
- maverick_channels-0.1.2/tests/test_cli_channel.py +85 -0
- maverick_channels-0.1.2/tests/test_hardening_round.py +59 -0
- maverick_channels-0.1.2/tests/test_imessage_safe_send.py +64 -0
- maverick_channels-0.1.2/tests/test_sms_signature.py +57 -0
- 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
|