Skip to content
Merged
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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,43 @@

## Unreleased

## 0.5.0rc2 (2026-05-13)

Adds Telegram as the **recommended default notifier** for new consumers.

### Added

- **`TelegramNotifier` + `TelegramNotifierConfig`.** Sends alerts via the
Telegram Bot API. Setup is two minutes (message `@BotFather` → `/newbot`
→ save the token → grab the `chat_id` from
`https://api.telegram.org/bot<TOKEN>/getUpdates`). One bot fans out to
N flows via `chat_id` and the optional `message_thread_id` (forum
topics in supergroups), no per-channel webhook required.
Env-var contract: `FLOW_DOCTOR_TELEGRAM_BOT_TOKEN` +
`FLOW_DOCTOR_TELEGRAM_CHAT_ID` (with `TELEGRAM_BOT_TOKEN` /
`TELEGRAM_CHAT_ID` as conventional aliases).
Numeric env chat_ids auto-coerce to `int`; `@channelusername` style
stays `str`. Persisted action target is the non-secret
`telegram:<chat_id>[:<thread>]` identifier — never the bot token.
- **`ActionType.TELEGRAM_ALERT`** in the persisted action enum.
- **Telegram parse / payload knobs** in both the typed config and the
yaml-driven omnibus form: `parse_mode` (default `"Markdown"`),
`disable_notification`, `message_thread_id`.
- **Preflight bypass parity.** `TelegramNotifier.validate()` calls
`/getMe` to fail fast on a revoked bot token, with the same
`FLOW_DOCTOR_SKIP_PREFLIGHT=1` opt-out the other notifiers use for
tests / offline boot.

### Rationale

For single-dev and small-team ops, Telegram beats SMTP/SES/Slack on
setup cost (no app password, no verified-identity dance, no workspace
admin), routing (per-chat or per-thread is built in), and mobile UX
(push is automatic). Slack / Email / GitHub / S3 stay as alternates;
the change is to which notifier the README + builder examples lead with.

Suite: 376/376 pass (+20 new Telegram tests).

## 0.5.0rc1 (2026-05-13)

Release-candidate cut of the "plug-and-play" release for internal soak.
Expand Down
4 changes: 3 additions & 1 deletion flow_doctor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
NotifierConfig,
S3NotifierConfig,
SlackNotifierConfig,
TelegramNotifierConfig,
)

__all__ = [
Expand All @@ -28,8 +29,9 @@
"S3NotifierConfig",
"Severity",
"SlackNotifierConfig",
"TelegramNotifierConfig",
"context",
"current_context",
"init",
]
__version__ = "0.5.0rc1"
__version__ = "0.5.0rc2"
48 changes: 47 additions & 1 deletion flow_doctor/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
"slack_webhook": ["FLOW_DOCTOR_SLACK_WEBHOOK", "SLACK_WEBHOOK_URL"],
"anthropic_api_key": ["FLOW_DOCTOR_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"],
"s3_bucket": ["FLOW_DOCTOR_S3_BUCKET", "CHANGELOG_BUCKET"],
"telegram_bot_token": ["FLOW_DOCTOR_TELEGRAM_BOT_TOKEN", "TELEGRAM_BOT_TOKEN"],
"telegram_chat_id": ["FLOW_DOCTOR_TELEGRAM_CHAT_ID", "TELEGRAM_CHAT_ID"],
}


Expand Down Expand Up @@ -288,10 +290,51 @@ def _init_notifiers(config: FlowDoctorConfig) -> List[Notifier]:
default_resolution_type=nc.default_resolution_type,
))

elif nc.type == "telegram":
bot_token = nc.bot_token or _env_fallback("telegram_bot_token")
raw_chat = nc.chat_id
if raw_chat is None:
raw_chat = _env_fallback("telegram_chat_id")
# chat_id from env is a string; coerce to int when
# it's a numeric id (negative for channels/groups,
# positive for users) so the Bot API receives the
# right JSON type. ``@channelusername`` style stays str.
if raw_chat is not None and (
raw_chat.lstrip("-").isdigit()
):
raw_chat = int(raw_chat)
missing = []
if not bot_token:
missing.append(
f"bot_token (or one of: "
f"{', '.join(_ENV_FALLBACKS['telegram_bot_token'])}). "
"Create one via @BotFather → /newbot"
)
if raw_chat in (None, ""):
missing.append(
f"chat_id (or one of: "
f"{', '.join(_ENV_FALLBACKS['telegram_chat_id'])}). "
"After messaging the bot, look it up at "
"https://api.telegram.org/bot<TOKEN>/getUpdates"
)
if missing:
raise ConfigError(
f"{label}: telegram notifier is missing required field(s): "
f"{'; '.join(missing)}."
)
from flow_doctor.notify.telegram import TelegramNotifier
notifiers.append(TelegramNotifier(
bot_token=bot_token,
chat_id=raw_chat,
message_thread_id=nc.message_thread_id,
parse_mode=nc.parse_mode,
disable_notification=nc.disable_notification,
))

else:
raise ConfigError(
f"{label}: unknown notifier type '{nc.type}'. "
f"Supported types: slack, email, github, s3."
f"Supported types: slack, email, github, s3, telegram."
)

return notifiers
Expand Down Expand Up @@ -708,6 +751,7 @@ def _send_notifications(
from flow_doctor.notify.email import EmailNotifier
from flow_doctor.notify.github import GitHubNotifier
from flow_doctor.notify.s3 import S3Notifier
from flow_doctor.notify.telegram import TelegramNotifier

if isinstance(notifier, SlackNotifier):
action_type = ActionType.SLACK_ALERT.value
Expand All @@ -717,6 +761,8 @@ def _send_notifications(
action_type = ActionType.GITHUB_ISSUE.value
elif isinstance(notifier, S3Notifier):
action_type = ActionType.S3_ALERT.value
elif isinstance(notifier, TelegramNotifier):
action_type = ActionType.TELEGRAM_ALERT.value
else:
action_type = "unknown_alert"

Expand Down
16 changes: 14 additions & 2 deletions flow_doctor/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union

import yaml
from pydantic import BaseModel, ConfigDict, Field
Expand Down Expand Up @@ -46,7 +46,7 @@ class _ConfigModel(BaseModel):
category=None,
)
class NotifyChannelConfig(_ConfigModel):
type: str # "slack", "email", "github", or "s3"
type: str # "slack", "email", "github", "s3", or "telegram"
# Slack fields
webhook_url: Optional[str] = None
channel: Optional[str] = None
Expand All @@ -68,6 +68,13 @@ class NotifyChannelConfig(_ConfigModel):
entry_prefix: str = "changelog/entries"
default_root_cause_category: str = "code_bug"
default_resolution_type: Optional[str] = None
# Telegram fields (Bot API). bot_token + chat_id are required at
# init time; message_thread_id is optional for forum topics.
bot_token: Optional[str] = None
chat_id: Optional[Union[int, str]] = None
message_thread_id: Optional[int] = None
parse_mode: Optional[str] = "Markdown"
disable_notification: bool = False


class StoreConfig(_ConfigModel):
Expand Down Expand Up @@ -278,6 +285,11 @@ def _parse_notify_dicts(items: List[Dict]) -> List[NotifyChannelConfig]:
entry_prefix=item.get("entry_prefix", "changelog/entries"),
default_root_cause_category=item.get("default_root_cause_category", "code_bug"),
default_resolution_type=item.get("default_resolution_type"),
bot_token=item.get("bot_token"),
chat_id=item.get("chat_id"),
message_thread_id=item.get("message_thread_id"),
parse_mode=item.get("parse_mode", "Markdown"),
disable_notification=item.get("disable_notification", False),
))
return configs

Expand Down
1 change: 1 addition & 0 deletions flow_doctor/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class ActionType(str, Enum):
GITHUB_ISSUE = "github_issue"
GITHUB_PR = "github_pr"
S3_ALERT = "s3_alert"
TELEGRAM_ALERT = "telegram_alert"


class ActionStatus(str, Enum):
Expand Down
2 changes: 2 additions & 0 deletions flow_doctor/notify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
NotifierConfig,
S3NotifierConfig,
SlackNotifierConfig,
TelegramNotifierConfig,
)

__all__ = [
Expand All @@ -14,4 +15,5 @@
"NotifierConfig",
"S3NotifierConfig",
"SlackNotifierConfig",
"TelegramNotifierConfig",
]
43 changes: 43 additions & 0 deletions flow_doctor/notify/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@

from flow_doctor.core.config import NotifyChannelConfig

# Telegram chat_id may be an integer (typical) or a "@channelusername"
# string (public channels only). The typed config preserves the
# union; the legacy omnibus form already accepts ``Union[int, str]``.
TelegramChatId = Union[int, str]


class _NotifierConfigBase(BaseModel):
model_config = ConfigDict(extra="ignore", validate_assignment=False)
Expand Down Expand Up @@ -107,6 +112,42 @@ def to_channel_config(self) -> NotifyChannelConfig:
)


class TelegramNotifierConfig(_NotifierConfigBase):
"""Recommended default notifier (since 0.5.0rc2).

Setup: message ``@BotFather`` → ``/newbot`` → save the bot token.
Add the bot to your target chat. Look up the ``chat_id`` via
``GET https://api.telegram.org/bot<TOKEN>/getUpdates`` after sending
the bot a message. For forum-style supergroups, also note the
``message_thread_id`` of the topic you want notifications routed to.
"""

type: Literal["telegram"] = "telegram"
bot_token: Optional[str] = None
chat_id: Optional[TelegramChatId] = None
# Forum supergroups support per-topic routing — use this to fan out
# N flow-doctor flows (am, pm, alpha-engine, predictor, ...) into
# one chat without N bots.
message_thread_id: Optional[int] = None
# Telegram parse_mode for the message body. ``Markdown`` matches
# the legacy / minimal Markdown flavour; pass ``MarkdownV2`` for
# the strict escaping mode, ``HTML`` for HTML, or ``None`` for
# plain text.
parse_mode: Optional[str] = "Markdown"
# Silent delivery — Telegram still pushes but without a sound.
disable_notification: bool = False

def to_channel_config(self) -> NotifyChannelConfig:
return NotifyChannelConfig(
type="telegram",
bot_token=self.bot_token,
chat_id=self.chat_id,
message_thread_id=self.message_thread_id,
parse_mode=self.parse_mode,
disable_notification=self.disable_notification,
)


# Discriminated union of all typed notifier configs. Consumers can
# type-hint as ``NotifierConfig`` and Pydantic will pick the right
# concrete model based on the ``type`` field.
Expand All @@ -116,6 +157,7 @@ def to_channel_config(self) -> NotifyChannelConfig:
EmailNotifierConfig,
GitHubNotifierConfig,
S3NotifierConfig,
TelegramNotifierConfig,
],
Field(discriminator="type"),
]
Expand All @@ -127,4 +169,5 @@ def to_channel_config(self) -> NotifyChannelConfig:
"NotifierConfig",
"S3NotifierConfig",
"SlackNotifierConfig",
"TelegramNotifierConfig",
]
Loading
Loading