Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ Revisions happen in the same persistent planner session — full context preserv

### 🔔 Notifications

Async communication between agent and human, delivered to both web UI and Telegram.
Async communication between agent and human, delivered to the web UI, Telegram,
and Slack. `notifications.channels` chooses which of them; all three are on by
default and a transport that is off is skipped.

- **`notify`** — Fire-and-forget alerts (status updates, completions, reminders)
- **`ask_user`** — Questions with predefined options, rendered as buttons
Expand Down
9 changes: 9 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@ slack:
# doctor/restart affect the host; sessions lists other channels. Opt in.
# commands: [sessions, new, stop, reply]

# Where notify, ask_user, and propose_action deliver. The list replaces the
# default rather than adding to it, so name every transport you want. A
# transport that is off costs nothing here.
notifications:
channels: [web, telegram, slack]
# Target conversation for Slack cards. Without this, the first literal ID
# in slack.allow_channels is used; names and globs are not resolved.
# slack_channel_id: "C0456DEF"

# Quiet hours (local timezone)
quiet_start: "02:00"
quiet_end: "12:00"
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ Async notification system for agent→user communication:
- **`notify` tool** — fire-and-forget notifications (status updates, alerts, reminders)
- **`ask_user` tool** — questions with predefined options (rendered as buttons) + free-text input. Supports blocking mode (`wait=true`) and async mode (answer injected as session message)
- **NotificationService** — centralized fanout to configurable channels (web + Telegram by default), answer routing, periodic expiry
- **Multi-channel delivery** — web UI via `__global__` WebSocket broadcast channel, Telegram via direct bot API with inline keyboard buttons for questions
- **Answer routing** — answers from any channel (web UI, Telegram inline button, `/reply` command) are persisted and either unblock a waiting tool or injected as a user message into the originating session
- **Multi-channel delivery** — web UI via `__global__` WebSocket broadcast channel, Telegram via direct bot API with inline keyboard buttons, Slack via Block Kit action buttons
- **Answer routing** — answers from any channel (web UI, Telegram inline button, Slack button, `/reply` command) are persisted and either unblock a waiting tool or injected as a user message into the originating session
- **Web UI** — `/notifications` page with status/type filters, inline answer buttons, dismiss, dismiss-all; real-time toast overlay for new notifications; NavRail badge for pending count

### Cron Service (`nerve/cron/`)
Expand Down
16 changes: 16 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,22 @@ Slack slash-command payloads have no thread ID. In a shared channel, `new` and
`sessions` therefore refuse, while `stop`, `star`, and `unstar` select among
that channel's active thread sessions. Commands work normally in DMs.

### Notifications

Question and approval cards go to Slack by default.

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `notifications.channels` | list | `[web, telegram, slack]` | Where `notify`, `ask_user`, and `propose_action` deliver |
| `notifications.slack_channel_id` | string | `""` | Target channel ID; defaults to the first literal ID in `slack.allow_channels` |

`notifications.channels` replaces the default rather than adding to it, so
list every transport you want. A name nothing delivers to is skipped with a
warning. Slack in the list costs nothing while Slack is off.

Names and globs are not resolved for the `slack_channel_id` fallback. Without
a literal channel ID, delivery is skipped with a warning.

## Quiet Hours

| Key | Type | Default | Description |
Expand Down
84 changes: 79 additions & 5 deletions nerve/channels/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
_MAX_ACTION_ELEMENTS,
_SESSIONS_BUTTON_LIMIT,
_md_to_slack,
build_notification_blocks,
build_sessions_blocks,
slack_emoji_name,
slack_to_plain,
Expand Down Expand Up @@ -1025,6 +1026,16 @@ async def _handle_reaction_event(self, event: dict[str, Any]) -> None:
return
target, original_text = cached

_, thread_ts = parse_target(target)
if not channel_id.startswith("D") and thread_ts is None:
# A shared channel has no conversation-wide session: each thread
# owns one. A message cached at channel level, such as a
# notification card, has no thread for a reaction to join, and
# opening one would write a slack:<channel> mapping that the
# session pickers deliberately do not list. A DM is one
# conversation, so it has no thread to require.
return

channel_type = "im" if channel_id.startswith("D") else "channel"
if not await self._authorize(user_id, channel_id, channel_type):
return
Expand Down Expand Up @@ -1202,6 +1213,67 @@ async def _post(
)
return resp.get("ts")

def _notification_target(self) -> str | None:
"""Resolve a concrete conversation from the active config generation."""
configured = self.config.notifications.slack_channel_id.strip()
if configured:
if is_slack_id(configured) and configured[0] in "CGD":
return configured
logger.warning(
"notifications.slack_channel_id is not a Slack conversation id",
)
return None

for entry in self.config.slack.allow_channels:
if is_slack_id(entry) and entry[0] in "CG":
return entry
logger.warning(
"No notifications.slack_channel_id is set and slack.allow_channels "
"has no literal conversation id",
)
return None

async def post_notification(
self,
notification_id: str,
text: str,
options: list[tuple[str, str]] | None = None,
) -> tuple[str, str] | None:
"""Render and post one notification using the active Slack config."""
if not self.is_available:
return None
target = self._notification_target()
if not target:
return None
blocks = build_notification_blocks(text, notification_id, options)
message_id = await self._post(target, text, blocks)
if not message_id:
return None
self._cache_message(message_id, target, text)
return target, message_id

async def expire_notification(
self,
target: str,
message_id: str,
text: str,
) -> None:
"""Replace a notification card with its expired state."""
if not self.is_available:
return
channel_id, _ = parse_target(target)
try:
await self._web.chat_update(
channel=channel_id,
ts=message_id,
text=_md_to_slack(text),
blocks=[],
)
except Exception as exc:
logger.debug(
"Slack expiry edit failed for %s: %s", message_id, exc,
)

async def send(self, message: OutboundMessage) -> None:
"""Send a complete message, split to fit Slack's render limit.

Expand Down Expand Up @@ -1922,15 +1994,17 @@ async def _handle_notification_button(
return

actor = (payload.get("user") or {}).get("id") or ""
thread_ts = (payload.get("message") or {}).get("thread_ts") or None
# A card is posted at conversation level, so the target recorded for
# it is the bare conversation. Slack fills in thread_ts on any
# message that has replies, so carrying it across from the press
# would stop matching that record the moment somebody replied under
# the card, and every later press would read as already answered.
target = format_target((payload.get("channel") or {}).get("id") or "")
result = await self._notification_service.answer_delivered_notification(
notification_id,
answer,
channel="slack",
target=format_target(
(payload.get("channel") or {}).get("id") or "",
thread_ts,
),
target=target,
actor=actor,
)
if not result:
Expand Down
8 changes: 6 additions & 2 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2039,8 +2039,11 @@ def from_dict(cls, d: dict) -> AuthConfig:
@dataclass
class NotificationsConfig:
"""Async notification delivery settings."""
channels: list[str] = field(default_factory=lambda: ["web", "telegram"])
channels: list[str] = field(
default_factory=lambda: ["web", "telegram", "slack"],
)
telegram_chat_id: int | None = None # Target chat; falls back to first allowed_user
slack_channel_id: str = "" # Target conversation; falls back to a literal id in slack.allow_channels
default_expiry_hours: int = 48 # Auto-expire unanswered questions
max_redeliveries: int = 3 # Per-row cap on snooze/re-delivery cycles
priority_prefixes: dict[str, str] = field(default_factory=lambda: {
Expand All @@ -2057,8 +2060,9 @@ class NotificationsConfig:
@_coerced
def from_dict(cls, d: dict) -> NotificationsConfig:
return cls(
channels=d.get("channels", ["web", "telegram"]),
channels=d.get("channels", ["web", "telegram", "slack"]),
telegram_chat_id=d.get("telegram_chat_id"),
slack_channel_id=str(d.get("slack_channel_id") or ""),
default_expiry_hours=d.get("default_expiry_hours", 48),
max_redeliveries=d.get("max_redeliveries", 3),
priority_prefixes=d.get("priority_prefixes", {
Expand Down
16 changes: 16 additions & 0 deletions nerve/db/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,22 @@ async def get_notification_delivery(
row = await cursor.fetchone()
return dict(row) if row else None

async def get_latest_notification_delivery(
self,
notification_id: str,
channel: str,
) -> dict | None:
"""Return the most recent delivery through one transport."""
async with self.db.execute(
"""SELECT * FROM notification_deliveries
WHERE notification_id = ? AND channel = ?
ORDER BY delivered_at DESC, rowid DESC
LIMIT 1""",
(notification_id, channel),
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None

async def find_pending_question_for_delivery(
self,
channel: str,
Expand Down
Loading
Loading