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.
Files changed (60) hide show
  1. pboy/__init__.py +25 -0
  2. pboy/channels/README.md +128 -0
  3. pboy/channels/__init__.py +16 -0
  4. pboy/channels/base.py +46 -0
  5. pboy/channels/config.py +103 -0
  6. pboy/channels/fake.py +58 -0
  7. pboy/channels/registry.py +230 -0
  8. pboy/channels/slack.py +255 -0
  9. pboy/channels/telegram.py +200 -0
  10. pboy/cli/README.md +60 -0
  11. pboy/cli/__init__.py +9 -0
  12. pboy/cli/daemon_client.py +183 -0
  13. pboy/cli/main.py +582 -0
  14. pboy/core/README.md +41 -0
  15. pboy/core/__init__.py +51 -0
  16. pboy/core/config.py +106 -0
  17. pboy/core/engine.py +250 -0
  18. pboy/core/filters.py +42 -0
  19. pboy/core/lease.py +90 -0
  20. pboy/core/normalizer.py +198 -0
  21. pboy/core/router.py +42 -0
  22. pboy/daemon/__init__.py +5 -0
  23. pboy/daemon/app.py +306 -0
  24. pboy/daemon/auth.py +295 -0
  25. pboy/daemon/schemas.py +133 -0
  26. pboy/daemon/server.py +96 -0
  27. pboy/harness/README.md +63 -0
  28. pboy/harness/__init__.py +5 -0
  29. pboy/harness/executor.py +190 -0
  30. pboy/models/README.md +48 -0
  31. pboy/models/__init__.py +30 -0
  32. pboy/models/cloudevent.py +77 -0
  33. pboy/models/completion.py +65 -0
  34. pboy/models/execution.py +43 -0
  35. pboy/models/hook.py +80 -0
  36. pboy/outbound/__init__.py +7 -0
  37. pboy/outbound/telegram.py +105 -0
  38. pboy/py.typed +0 -0
  39. pboy/store/README.md +63 -0
  40. pboy/store/__init__.py +6 -0
  41. pboy/store/base.py +104 -0
  42. pboy/store/sqlite.py +459 -0
  43. pboy/ui/README.md +50 -0
  44. pboy/ui/__init__.py +5 -0
  45. pboy/ui/guides.py +212 -0
  46. pboy/ui/mount.py +338 -0
  47. pboy/ui/static/dashboard.css +433 -0
  48. pboy/ui/static/htmx.min.js +1 -0
  49. pboy/ui/templates/base.html +37 -0
  50. pboy/ui/templates/dashboard.html +51 -0
  51. pboy/ui/templates/login.html +44 -0
  52. pboy/ui/templates/partials/events.html +24 -0
  53. pboy/ui/templates/partials/executions.html +28 -0
  54. pboy/ui/templates/partials/hooks.html +30 -0
  55. pboy/ui/templates/settings.html +152 -0
  56. pboy-0.1.0.dist-info/METADATA +250 -0
  57. pboy-0.1.0.dist-info/RECORD +60 -0
  58. pboy-0.1.0.dist-info/WHEEL +4 -0
  59. pboy-0.1.0.dist-info/entry_points.txt +3 -0
  60. 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"
@@ -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"]
@@ -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"]