pboy 0.1.0__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.
- pboy/__init__.py +25 -0
- pboy/channels/README.md +128 -0
- pboy/channels/__init__.py +16 -0
- pboy/channels/base.py +46 -0
- pboy/channels/config.py +103 -0
- pboy/channels/fake.py +58 -0
- pboy/channels/registry.py +230 -0
- pboy/channels/slack.py +255 -0
- pboy/channels/telegram.py +200 -0
- pboy/cli/README.md +60 -0
- pboy/cli/__init__.py +9 -0
- pboy/cli/daemon_client.py +183 -0
- pboy/cli/main.py +582 -0
- pboy/core/README.md +41 -0
- pboy/core/__init__.py +51 -0
- pboy/core/config.py +106 -0
- pboy/core/engine.py +250 -0
- pboy/core/filters.py +42 -0
- pboy/core/lease.py +90 -0
- pboy/core/normalizer.py +198 -0
- pboy/core/router.py +42 -0
- pboy/daemon/__init__.py +5 -0
- pboy/daemon/app.py +306 -0
- pboy/daemon/auth.py +295 -0
- pboy/daemon/schemas.py +133 -0
- pboy/daemon/server.py +96 -0
- pboy/harness/README.md +63 -0
- pboy/harness/__init__.py +5 -0
- pboy/harness/executor.py +190 -0
- pboy/models/README.md +48 -0
- pboy/models/__init__.py +30 -0
- pboy/models/cloudevent.py +77 -0
- pboy/models/completion.py +65 -0
- pboy/models/execution.py +43 -0
- pboy/models/hook.py +80 -0
- pboy/outbound/__init__.py +7 -0
- pboy/outbound/telegram.py +105 -0
- pboy/py.typed +0 -0
- pboy/store/README.md +63 -0
- pboy/store/__init__.py +6 -0
- pboy/store/base.py +104 -0
- pboy/store/sqlite.py +459 -0
- pboy/ui/README.md +50 -0
- pboy/ui/__init__.py +5 -0
- pboy/ui/guides.py +212 -0
- pboy/ui/mount.py +338 -0
- pboy/ui/static/dashboard.css +433 -0
- pboy/ui/static/htmx.min.js +1 -0
- pboy/ui/templates/base.html +37 -0
- pboy/ui/templates/dashboard.html +51 -0
- pboy/ui/templates/login.html +44 -0
- pboy/ui/templates/partials/events.html +24 -0
- pboy/ui/templates/partials/executions.html +28 -0
- pboy/ui/templates/partials/hooks.html +30 -0
- pboy/ui/templates/settings.html +152 -0
- pboy-0.1.0.dist-info/METADATA +250 -0
- pboy-0.1.0.dist-info/RECORD +60 -0
- pboy-0.1.0.dist-info/WHEEL +4 -0
- pboy-0.1.0.dist-info/entry_points.txt +3 -0
- pboy-0.1.0.dist-info/licenses/LICENSE +202 -0
pboy/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Pageboy (`pboy` / `pb`)
|
|
2
|
+
"""
|
|
3
|
+
pboy — Zero-Ops Agent Event Orchestrator & Dispatcher
|
|
4
|
+
|
|
5
|
+
Slack, Telegram, Jira, Confluence 의 멀티 채널 이벤트를 로컬 머신에서 수신하고,
|
|
6
|
+
이벤트 구동 방식으로 에이전트 하네스 (Harness) 를 실행·동기화하는 도구입니다.
|
|
7
|
+
|
|
8
|
+
사용법:
|
|
9
|
+
uvx pboy daemon --ui # 데몬 + 웹 UI 시작
|
|
10
|
+
pboy hook register --name ... --exec ... # 훅 등록
|
|
11
|
+
pboy state put --event-id ... --status running # 하네스 내부 상태 보고
|
|
12
|
+
pboy event complete --event-id ... --summary ... # 완료 처리
|
|
13
|
+
|
|
14
|
+
패키지 구조:
|
|
15
|
+
cli/ — CLI 명령어 (hook, state, event, daemon, ui)
|
|
16
|
+
core/ — EventNormalizer, HookRouter, LeaseEngine
|
|
17
|
+
channels/ — Telegram, Slack, Jira/Confluence 수신부
|
|
18
|
+
store/ — SQLite AES 암호화 저장소
|
|
19
|
+
harness/ — Subprocess executor (fork/exec)
|
|
20
|
+
ui/ — 임베디드 로컬 웹 대시보드
|
|
21
|
+
models/ — CloudEvents v1.0 호환 도메인 모델
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
__author__ = "fritzprix"
|
pboy/channels/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Channels — `pboy.channels`
|
|
2
|
+
|
|
3
|
+
Inbound **wake-up** receivers. Adapters push **raw** payloads into `Engine.process_event`
|
|
4
|
+
(normalization stays in `pboy.core.normalizer`).
|
|
5
|
+
|
|
6
|
+
Ticketing systems (Jira / Confluence) are **not** required ChannelAdapters: agents
|
|
7
|
+
call their REST APIs from the harness. See [`docs/architecture-channels.md`](../../../docs/architecture-channels.md).
|
|
8
|
+
|
|
9
|
+
## Module layout
|
|
10
|
+
|
|
11
|
+
| File | Role |
|
|
12
|
+
|------|------|
|
|
13
|
+
| `base.py` | `ChannelAdapter` / `EventEmitter` protocols |
|
|
14
|
+
| `registry.py` | Enable/start/stop via `PB_CHANNELS_ENABLED` |
|
|
15
|
+
| `fake.py` | In-process fake adapter (tests / demos) |
|
|
16
|
+
| `telegram.py` | Bot API long-poll (`getUpdates`) |
|
|
17
|
+
| `slack.py` | Socket Mode (Events API over WebSocket) |
|
|
18
|
+
|
|
19
|
+
## Enable channels
|
|
20
|
+
|
|
21
|
+
**Preferred (persisted store + CLI):**
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pboy channel set-secret telegram --value '123456:ABC...'
|
|
25
|
+
pboy channel enable telegram
|
|
26
|
+
pboy channel list
|
|
27
|
+
# • telegram [enabled, stopped, secret=yes] # until daemon starts it
|
|
28
|
+
|
|
29
|
+
export PB_AUTH_TOKEN=dev-secret
|
|
30
|
+
pboy daemon --host 127.0.0.1 --port 8080
|
|
31
|
+
# /healthz → channels.running includes "telegram"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
With `PB_DAEMON_URL` set, the same `pboy channel …` commands hit the daemon HTTP API
|
|
35
|
+
(and can start/stop adapters live).
|
|
36
|
+
|
|
37
|
+
**Env override** (wins over store when the variable is set, including empty):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
export PB_CHANNELS_ENABLED=telegram
|
|
41
|
+
export PB_TELEGRAM_BOT_TOKEN='123456:ABC...'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Telegram setup (BotFather)
|
|
45
|
+
|
|
46
|
+
1. In Telegram, talk to [@BotFather](https://t.me/BotFather) → `/newbot` → copy the token.
|
|
47
|
+
2. Store the token and enable the channel:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pboy channel set-secret telegram --value '<token from BotFather>'
|
|
51
|
+
pboy channel enable telegram
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or use env: `PB_TELEGRAM_BOT_TOKEN` (overrides store when set).
|
|
55
|
+
|
|
56
|
+
3. Start the daemon (same DB as the CLI):
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
export PB_AUTH_TOKEN=dev-secret
|
|
60
|
+
pboy daemon --host 127.0.0.1 --port 8080
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
4. Register a hook for `telegram:message`, then message the bot:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
export PB_DAEMON_URL=http://127.0.0.1:8080
|
|
67
|
+
pboy hook register \
|
|
68
|
+
--name tg-echo \
|
|
69
|
+
--topic 'telegram:message' \
|
|
70
|
+
--exec 'echo hello-from-tg' \
|
|
71
|
+
--timeout 30
|
|
72
|
+
pboy channel list
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Long-poll calls `deleteWebhook` on start so it does not fight a webhook subscription.
|
|
76
|
+
On API errors the poller backs off exponentially (1s → 60s).
|
|
77
|
+
|
|
78
|
+
## Outbound sync (completion reply)
|
|
79
|
+
|
|
80
|
+
When a harness finishes work it can close the loop:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pboy event complete "$PB_EVENT_ID" \
|
|
84
|
+
--summary 'Restarted worker-2' \
|
|
85
|
+
--sync telegram
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This uses the same bot token, posts to `event.data.channel_id`, and replies to
|
|
89
|
+
`event.data.entity_key` (message id) when numeric. Failed sync exits CLI non-zero
|
|
90
|
+
but the completion record is still persisted. Re-running
|
|
91
|
+
`pboy event complete … --sync telegram` retries **failed/missing** sync targets
|
|
92
|
+
only (successful ones are not re-sent; original summary is kept).
|
|
93
|
+
|
|
94
|
+
## Adapter contract
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
class ChannelAdapter(Protocol):
|
|
98
|
+
@property
|
|
99
|
+
def name(self) -> str: ...
|
|
100
|
+
async def start(self) -> None: ...
|
|
101
|
+
async def stop(self) -> None: ...
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Transports
|
|
105
|
+
|
|
106
|
+
| Channel | Transport | Tunnel | Role |
|
|
107
|
+
|---------|-----------|--------|------|
|
|
108
|
+
| Telegram | Long-poll `getUpdates` | No | Wake-up ✅ |
|
|
109
|
+
| Slack | Socket Mode | No | Wake-up ✅ |
|
|
110
|
+
| Jira / Confluence | **Agent REST** (GET/POST) | No | Work surface — not a ChannelAdapter |
|
|
111
|
+
| Jira webhook + relay | Push HTTPS | Public URL | Deferred / optional only |
|
|
112
|
+
|
|
113
|
+
## Slack setup (Socket Mode)
|
|
114
|
+
|
|
115
|
+
1. Create a Slack app with **Socket Mode** enabled; generate an **App-level token**
|
|
116
|
+
(`xapp-…`) with `connections:write`, and a **Bot token** (`xoxb-…`) with
|
|
117
|
+
`chat:write` + event subscriptions for `message.channels` / `message.im` as needed.
|
|
118
|
+
2. Store secrets (both required):
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pboy channel set-secret slack --secret-name bot_token --value 'xoxb-...'
|
|
122
|
+
pboy channel set-secret slack --secret-name app_token --value 'xapp-...'
|
|
123
|
+
pboy channel enable slack
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Or env: `PB_SLACK_BOT_TOKEN` + `PB_SLACK_APP_TOKEN`.
|
|
127
|
+
|
|
128
|
+
3. Start daemon, register a hook on `slack:message`, then message the app.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Inbound channel adapters (Telegram, Slack, …)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pboy.channels.base import ChannelAdapter, EventEmitter
|
|
6
|
+
from pboy.channels.registry import ChannelRegistry
|
|
7
|
+
from pboy.channels.slack import SlackChannel
|
|
8
|
+
from pboy.channels.telegram import TelegramChannel
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ChannelAdapter",
|
|
12
|
+
"ChannelRegistry",
|
|
13
|
+
"EventEmitter",
|
|
14
|
+
"SlackChannel",
|
|
15
|
+
"TelegramChannel",
|
|
16
|
+
]
|
pboy/channels/base.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Channel adapter protocol — inbound platforms push raw events into the Engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def redact_secret(message: str, secret: str | None) -> str:
|
|
9
|
+
"""Replace a secret substring in log/error text when present."""
|
|
10
|
+
if secret:
|
|
11
|
+
return message.replace(secret, "[REDACTED]")
|
|
12
|
+
return message
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class EventEmitter(Protocol):
|
|
17
|
+
"""Sink used by adapters to hand raw platform payloads to the Engine."""
|
|
18
|
+
|
|
19
|
+
async def __call__(
|
|
20
|
+
self,
|
|
21
|
+
raw_payload: dict[str, Any],
|
|
22
|
+
source: str,
|
|
23
|
+
*,
|
|
24
|
+
dispatch: bool = True,
|
|
25
|
+
) -> Any: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@runtime_checkable
|
|
29
|
+
class ChannelAdapter(Protocol):
|
|
30
|
+
"""Long-running inbound receiver (poll/socket/webhook consumer)."""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def name(self) -> str:
|
|
34
|
+
"""Stable adapter id (e.g. ``telegram``, ``fake``)."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
async def start(self) -> None:
|
|
38
|
+
"""Begin receiving (idempotent preferred)."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
async def stop(self) -> None:
|
|
42
|
+
"""Stop receiving and release resources (idempotent preferred)."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = ["ChannelAdapter", "EventEmitter", "redact_secret"]
|
pboy/channels/config.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Persisted channel enablement and per-channel secret key names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from pboy.store.base import StoreBase
|
|
8
|
+
|
|
9
|
+
CHANNELS_ENABLED_SECRET = "channels.enabled"
|
|
10
|
+
|
|
11
|
+
# channel name → required encrypted store secret keys
|
|
12
|
+
CHANNEL_SECRET_KEYS: dict[str, tuple[str, ...]] = {
|
|
13
|
+
"telegram": ("telegram.bot_token",),
|
|
14
|
+
"slack": ("slack.bot_token", "slack.app_token"),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_enabled_names(raw: str | None) -> list[str]:
|
|
19
|
+
"""Parse a comma-separated enable list into unique lowercase names."""
|
|
20
|
+
if not raw:
|
|
21
|
+
return []
|
|
22
|
+
names: list[str] = []
|
|
23
|
+
seen: set[str] = set()
|
|
24
|
+
for part in raw.split(","):
|
|
25
|
+
name = part.strip().lower()
|
|
26
|
+
if not name or name in seen:
|
|
27
|
+
continue
|
|
28
|
+
seen.add(name)
|
|
29
|
+
names.append(name)
|
|
30
|
+
return names
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_enabled_names(names: list[str]) -> str:
|
|
34
|
+
return ",".join(parse_enabled_names(",".join(names)))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def env_channels_enabled_override() -> str | None:
|
|
38
|
+
"""Return raw ``PB_CHANNELS_ENABLED`` when the env var is set (including empty)."""
|
|
39
|
+
if "PB_CHANNELS_ENABLED" in os.environ:
|
|
40
|
+
return os.environ["PB_CHANNELS_ENABLED"]
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def load_enabled_from_store(store: StoreBase) -> list[str]:
|
|
45
|
+
raw = await store.get_secret(CHANNELS_ENABLED_SECRET)
|
|
46
|
+
return parse_enabled_names(raw)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def save_enabled_to_store(store: StoreBase, names: list[str]) -> None:
|
|
50
|
+
await store.put_secret(CHANNELS_ENABLED_SECRET, format_enabled_names(names))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def secret_keys_for_channel(name: str) -> tuple[str, ...]:
|
|
54
|
+
return CHANNEL_SECRET_KEYS.get(name.strip().lower(), ())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_secret_key(channel: str, secret_name: str | None = None) -> str:
|
|
58
|
+
"""Resolve store key for ``pboy channel set-secret``.
|
|
59
|
+
|
|
60
|
+
Single-secret channels (telegram) omit ``secret_name``.
|
|
61
|
+
Multi-secret channels (slack) require ``bot_token`` / ``app_token`` (or full key).
|
|
62
|
+
"""
|
|
63
|
+
key = channel.strip().lower()
|
|
64
|
+
keys = CHANNEL_SECRET_KEYS.get(key)
|
|
65
|
+
if not keys:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Channel '{key}' has no secret mapping. Supported: {', '.join(sorted(CHANNEL_SECRET_KEYS))}"
|
|
68
|
+
)
|
|
69
|
+
if secret_name and secret_name.strip():
|
|
70
|
+
sn = secret_name.strip().lower()
|
|
71
|
+
full = sn if "." in sn else f"{key}.{sn}"
|
|
72
|
+
if full not in keys:
|
|
73
|
+
short = ", ".join(k.split(".", 1)[-1] for k in keys)
|
|
74
|
+
raise ValueError(f"Unknown secret '{secret_name}' for '{key}'. Use one of: {short}")
|
|
75
|
+
return full
|
|
76
|
+
if len(keys) == 1:
|
|
77
|
+
return keys[0]
|
|
78
|
+
short = ", ".join(k.split(".", 1)[-1] for k in keys)
|
|
79
|
+
raise ValueError(f"Channel '{key}' requires --secret-name ({short})")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def secret_key_for_channel(name: str) -> str | None:
|
|
83
|
+
"""Backward-compatible: primary secret key, or None if multi-secret without default."""
|
|
84
|
+
keys = secret_keys_for_channel(name)
|
|
85
|
+
if not keys:
|
|
86
|
+
return None
|
|
87
|
+
if len(keys) == 1:
|
|
88
|
+
return keys[0]
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = [
|
|
93
|
+
"CHANNELS_ENABLED_SECRET",
|
|
94
|
+
"CHANNEL_SECRET_KEYS",
|
|
95
|
+
"env_channels_enabled_override",
|
|
96
|
+
"format_enabled_names",
|
|
97
|
+
"load_enabled_from_store",
|
|
98
|
+
"parse_enabled_names",
|
|
99
|
+
"resolve_secret_key",
|
|
100
|
+
"save_enabled_to_store",
|
|
101
|
+
"secret_key_for_channel",
|
|
102
|
+
"secret_keys_for_channel",
|
|
103
|
+
]
|
pboy/channels/fake.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""In-process fake channel for tests and local demos (no network)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from pboy.channels.base import EventEmitter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeChannel:
|
|
13
|
+
"""Test/demo adapter: ``push`` injects a raw payload through the Engine sink."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
emit: EventEmitter,
|
|
18
|
+
*,
|
|
19
|
+
source: str = "jira",
|
|
20
|
+
name: str = "fake",
|
|
21
|
+
) -> None:
|
|
22
|
+
self._emit = emit
|
|
23
|
+
self._source = source
|
|
24
|
+
self._name = name
|
|
25
|
+
self._started = False
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def name(self) -> str:
|
|
29
|
+
return self._name
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def source(self) -> str:
|
|
33
|
+
return self._source
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def started(self) -> bool:
|
|
37
|
+
return self._started
|
|
38
|
+
|
|
39
|
+
async def start(self) -> None:
|
|
40
|
+
if self._started:
|
|
41
|
+
return
|
|
42
|
+
self._started = True
|
|
43
|
+
logger.info("channel.connect name={} source={}", self._name, self._source)
|
|
44
|
+
|
|
45
|
+
async def stop(self) -> None:
|
|
46
|
+
if not self._started:
|
|
47
|
+
return
|
|
48
|
+
self._started = False
|
|
49
|
+
logger.info("channel.disconnect name={} source={}", self._name, self._source)
|
|
50
|
+
|
|
51
|
+
async def push(self, payload: dict[str, Any], *, dispatch: bool = True) -> Any:
|
|
52
|
+
"""Emit a raw platform payload into the Engine (adapter must be started)."""
|
|
53
|
+
if not self._started:
|
|
54
|
+
raise RuntimeError(f"Channel '{self._name}' is not started")
|
|
55
|
+
return await self._emit(payload, self._source, dispatch=dispatch)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = ["FakeChannel"]
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Load and lifecycle-manage enabled channel adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from pboy.channels.base import ChannelAdapter, EventEmitter
|
|
11
|
+
from pboy.channels.config import (
|
|
12
|
+
env_channels_enabled_override,
|
|
13
|
+
load_enabled_from_store,
|
|
14
|
+
parse_enabled_names,
|
|
15
|
+
resolve_secret_key,
|
|
16
|
+
save_enabled_to_store,
|
|
17
|
+
secret_keys_for_channel,
|
|
18
|
+
)
|
|
19
|
+
from pboy.channels.fake import FakeChannel
|
|
20
|
+
from pboy.channels.slack import build_slack_channel
|
|
21
|
+
from pboy.channels.telegram import build_telegram_channel
|
|
22
|
+
from pboy.core.config import Settings, get_settings
|
|
23
|
+
from pboy.store.base import StoreBase
|
|
24
|
+
|
|
25
|
+
AdapterFactory = Callable[[EventEmitter, StoreBase, Settings], ChannelAdapter | None]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fake_factory(emit: EventEmitter, store: StoreBase, settings: Settings) -> ChannelAdapter:
|
|
29
|
+
del store, settings
|
|
30
|
+
return FakeChannel(emit)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
BUILTIN_FACTORIES: dict[str, AdapterFactory] = {
|
|
34
|
+
"fake": _fake_factory,
|
|
35
|
+
"telegram": build_telegram_channel,
|
|
36
|
+
"slack": build_slack_channel,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ChannelRegistry:
|
|
41
|
+
"""Register adapters and start only those enabled via env or store."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
emit: EventEmitter,
|
|
46
|
+
store: StoreBase,
|
|
47
|
+
*,
|
|
48
|
+
settings: Settings | None = None,
|
|
49
|
+
factories: dict[str, AdapterFactory] | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._emit = emit
|
|
52
|
+
self._store = store
|
|
53
|
+
self._settings = settings or get_settings()
|
|
54
|
+
self._factories = dict(BUILTIN_FACTORIES if factories is None else factories)
|
|
55
|
+
self._instances: dict[str, ChannelAdapter] = {}
|
|
56
|
+
self._running: list[str] = []
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def running(self) -> list[str]:
|
|
60
|
+
return list(self._running)
|
|
61
|
+
|
|
62
|
+
def known_names(self) -> list[str]:
|
|
63
|
+
return sorted(self._factories.keys() | self._instances.keys())
|
|
64
|
+
|
|
65
|
+
def register_factory(self, name: str, factory: AdapterFactory) -> None:
|
|
66
|
+
"""Register or replace a factory under a lowercase channel name."""
|
|
67
|
+
self._factories[name.strip().lower()] = factory
|
|
68
|
+
|
|
69
|
+
def register(self, adapter: ChannelAdapter) -> None:
|
|
70
|
+
"""Register a pre-built adapter instance (tests / custom wiring)."""
|
|
71
|
+
self._instances[adapter.name.strip().lower()] = adapter
|
|
72
|
+
|
|
73
|
+
def get(self, name: str) -> ChannelAdapter | None:
|
|
74
|
+
return self._instances.get(name.strip().lower())
|
|
75
|
+
|
|
76
|
+
async def resolve_enabled_names(self) -> list[str]:
|
|
77
|
+
"""Env ``PB_CHANNELS_ENABLED`` overrides store when the variable is set."""
|
|
78
|
+
override = env_channels_enabled_override()
|
|
79
|
+
if override is not None:
|
|
80
|
+
return parse_enabled_names(override)
|
|
81
|
+
return await load_enabled_from_store(self._store)
|
|
82
|
+
|
|
83
|
+
def _resolve(self, name: str) -> ChannelAdapter | None:
|
|
84
|
+
key = name.strip().lower()
|
|
85
|
+
if key in self._instances:
|
|
86
|
+
return self._instances[key]
|
|
87
|
+
factory = self._factories.get(key)
|
|
88
|
+
if factory is None:
|
|
89
|
+
logger.warning("channel.unknown name={} — skipping", key)
|
|
90
|
+
return None
|
|
91
|
+
adapter = factory(self._emit, self._store, self._settings)
|
|
92
|
+
if adapter is None:
|
|
93
|
+
logger.warning("channel.unavailable name={} — factory returned None", key)
|
|
94
|
+
return None
|
|
95
|
+
self._instances[key] = adapter
|
|
96
|
+
return adapter
|
|
97
|
+
|
|
98
|
+
async def start_enabled(self) -> None:
|
|
99
|
+
"""Start adapters listed as enabled; leave others untouched."""
|
|
100
|
+
enabled = await self.resolve_enabled_names()
|
|
101
|
+
if not enabled:
|
|
102
|
+
logger.info("channel.registry no channels enabled")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
for name in enabled:
|
|
106
|
+
await self._start_one(name)
|
|
107
|
+
|
|
108
|
+
async def _start_one(self, name: str) -> None:
|
|
109
|
+
key = name.strip().lower()
|
|
110
|
+
if key in self._running:
|
|
111
|
+
return
|
|
112
|
+
adapter = self._resolve(key)
|
|
113
|
+
if adapter is None:
|
|
114
|
+
return
|
|
115
|
+
await adapter.start()
|
|
116
|
+
self._running.append(key)
|
|
117
|
+
logger.info("channel.started name={}", key)
|
|
118
|
+
|
|
119
|
+
async def stop_all(self) -> None:
|
|
120
|
+
"""Stop every adapter that was started by this registry."""
|
|
121
|
+
for name in reversed(self._running):
|
|
122
|
+
adapter = self._instances.get(name)
|
|
123
|
+
if adapter is None:
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
await adapter.stop()
|
|
127
|
+
logger.info("channel.stopped name={}", name)
|
|
128
|
+
except Exception:
|
|
129
|
+
logger.exception("channel.stop_failed name={}", name)
|
|
130
|
+
self._running.clear()
|
|
131
|
+
|
|
132
|
+
async def enable(self, name: str) -> None:
|
|
133
|
+
"""Persist enablement and start the adapter if not already running."""
|
|
134
|
+
key = name.strip().lower()
|
|
135
|
+
if key not in self._factories and key not in self._instances:
|
|
136
|
+
raise ValueError(f"Unknown channel '{key}'. Known: {', '.join(self.known_names())}")
|
|
137
|
+
enabled = await self.resolve_enabled_names()
|
|
138
|
+
if key not in enabled:
|
|
139
|
+
enabled.append(key)
|
|
140
|
+
if env_channels_enabled_override() is None:
|
|
141
|
+
await save_enabled_to_store(self._store, enabled)
|
|
142
|
+
else:
|
|
143
|
+
logger.warning(
|
|
144
|
+
"channel.enable name={} — PB_CHANNELS_ENABLED is set; "
|
|
145
|
+
"store not updated (restart with env or unset override)",
|
|
146
|
+
key,
|
|
147
|
+
)
|
|
148
|
+
await self._start_one(key)
|
|
149
|
+
|
|
150
|
+
async def disable(self, name: str) -> None:
|
|
151
|
+
"""Persist disablement and stop the adapter if running."""
|
|
152
|
+
key = name.strip().lower()
|
|
153
|
+
enabled = [n for n in await self.resolve_enabled_names() if n != key]
|
|
154
|
+
if env_channels_enabled_override() is None:
|
|
155
|
+
await save_enabled_to_store(self._store, enabled)
|
|
156
|
+
else:
|
|
157
|
+
logger.warning(
|
|
158
|
+
"channel.disable name={} — PB_CHANNELS_ENABLED is set; store not updated",
|
|
159
|
+
key,
|
|
160
|
+
)
|
|
161
|
+
if key in self._running:
|
|
162
|
+
adapter = self._instances.get(key)
|
|
163
|
+
if adapter is not None:
|
|
164
|
+
await adapter.stop()
|
|
165
|
+
self._running = [n for n in self._running if n != key]
|
|
166
|
+
logger.info("channel.stopped name={}", key)
|
|
167
|
+
|
|
168
|
+
def env_covers_secret(self, secret_key: str) -> bool:
|
|
169
|
+
"""True when the matching ``PB_*`` env already supplies this secret."""
|
|
170
|
+
if secret_key == "telegram.bot_token":
|
|
171
|
+
return bool(self._settings.telegram_bot_token.strip())
|
|
172
|
+
if secret_key == "slack.bot_token":
|
|
173
|
+
return bool(self._settings.slack_bot_token.strip())
|
|
174
|
+
if secret_key == "slack.app_token":
|
|
175
|
+
return bool(self._settings.slack_app_token.strip())
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
async def set_channel_secret(
|
|
179
|
+
self,
|
|
180
|
+
name: str,
|
|
181
|
+
value: str,
|
|
182
|
+
*,
|
|
183
|
+
secret_name: str | None = None,
|
|
184
|
+
) -> str:
|
|
185
|
+
"""Store the channel token/secret; returns the secret key used."""
|
|
186
|
+
if not value.strip():
|
|
187
|
+
raise ValueError("Secret value must not be empty")
|
|
188
|
+
secret_key = resolve_secret_key(name, secret_name)
|
|
189
|
+
await self._store.put_secret(secret_key, value.strip())
|
|
190
|
+
return secret_key
|
|
191
|
+
|
|
192
|
+
async def list_status(self) -> list[dict[str, Any]]:
|
|
193
|
+
enabled = set(await self.resolve_enabled_names())
|
|
194
|
+
running = set(self._running)
|
|
195
|
+
rows: list[dict[str, Any]] = []
|
|
196
|
+
for name in self.known_names():
|
|
197
|
+
keys = secret_keys_for_channel(name)
|
|
198
|
+
has_secret = True
|
|
199
|
+
if keys:
|
|
200
|
+
for sk in keys:
|
|
201
|
+
if self.env_covers_secret(sk):
|
|
202
|
+
continue
|
|
203
|
+
if (await self._store.get_secret(sk)) is None:
|
|
204
|
+
has_secret = False
|
|
205
|
+
break
|
|
206
|
+
else:
|
|
207
|
+
has_secret = False
|
|
208
|
+
rows.append(
|
|
209
|
+
{
|
|
210
|
+
"name": name,
|
|
211
|
+
"enabled": name in enabled,
|
|
212
|
+
"running": name in running,
|
|
213
|
+
"secret_key": keys[0] if len(keys) == 1 else None,
|
|
214
|
+
"secret_keys": list(keys),
|
|
215
|
+
"has_secret": has_secret if keys else False,
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
return rows
|
|
219
|
+
|
|
220
|
+
async def status(self) -> dict[str, Any]:
|
|
221
|
+
"""Compact status for /healthz."""
|
|
222
|
+
return {
|
|
223
|
+
"enabled": await self.resolve_enabled_names(),
|
|
224
|
+
"running": self.running,
|
|
225
|
+
"known": self.known_names(),
|
|
226
|
+
"env_override": env_channels_enabled_override() is not None,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
__all__ = ["BUILTIN_FACTORIES", "AdapterFactory", "ChannelRegistry"]
|