feat(notifiers): structured-log subscriber + Telegram bidirectional bot
LogNotifier subscribes (Drop overflow) and emits stdlib Log lines with structured kwargs (credential_id, rotation_id, severity, etc.) suitable for downstream ingestion by journald/vector/fluentd. Telegram is a thin HTTP::Client wrapper for sendMessage and getUpdates. TelegramSubscriber sends emoji-prefixed alerts on RotationFailed, DriftDetected, PolicyViolation, AlertRaised; success notifications gated by the notify_on_success flag. Errors are swallowed so a flaky bot never blocks the engine. TelegramBot does long-polling getUpdates and dispatches commands: - viewer tier: /status /queue /history /alerts /help - operator tier: viewer + /rotate + /snooze ACL is by chat_id allowlist (bot token + chat IDs in env vars). /rotate publishes RotationScheduled to the bus. 11 unit specs cover send_message + error handling + getUpdates parsing, subscriber dispatch, success-suppression flag, and bot ACL enforcement (unauthorized / viewer-blocked-from-mutations / operator-can-rotate).
This commit is contained in:
parent
d7d5f10825
commit
67d65a934f
|
|
@ -0,0 +1,27 @@
|
||||||
|
# ===================
|
||||||
|
# ©AngelaMos | 2026
|
||||||
|
# log_notifier_spec.cr
|
||||||
|
# ===================
|
||||||
|
|
||||||
|
require "../../spec_helper"
|
||||||
|
require "../../../src/cre/notifiers/log_notifier"
|
||||||
|
require "../../../src/cre/events/credential_events"
|
||||||
|
|
||||||
|
describe CRE::Notifiers::LogNotifier do
|
||||||
|
it "subscribes and emits without errors on rotation events" do
|
||||||
|
bus = CRE::Engine::EventBus.new
|
||||||
|
notifier = CRE::Notifiers::LogNotifier.new(bus)
|
||||||
|
notifier.start
|
||||||
|
bus.run
|
||||||
|
|
||||||
|
cred_id = UUID.random
|
||||||
|
bus.publish CRE::Events::RotationCompleted.new(cred_id, UUID.random)
|
||||||
|
bus.publish CRE::Events::RotationFailed.new(cred_id, UUID.random, "test")
|
||||||
|
bus.publish CRE::Events::PolicyViolation.new(cred_id, "p", "stale")
|
||||||
|
bus.publish CRE::Events::AlertRaised.new(CRE::Events::Severity::Warn, "hi")
|
||||||
|
|
||||||
|
sleep 0.1.seconds
|
||||||
|
notifier.stop
|
||||||
|
bus.stop
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
# ===================
|
||||||
|
# ©AngelaMos | 2026
|
||||||
|
# telegram_bot_spec.cr
|
||||||
|
# ===================
|
||||||
|
|
||||||
|
require "../../spec_helper"
|
||||||
|
require "webmock"
|
||||||
|
require "../../../src/cre/notifiers/telegram_bot"
|
||||||
|
require "../../../src/cre/persistence/sqlite/sqlite_persistence"
|
||||||
|
|
||||||
|
WebMock.allow_net_connect = false
|
||||||
|
|
||||||
|
private def fresh_setup
|
||||||
|
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
|
||||||
|
persist.migrate!
|
||||||
|
bus = CRE::Engine::EventBus.new
|
||||||
|
bus.run
|
||||||
|
telegram = CRE::Notifiers::Telegram.new("FAKE")
|
||||||
|
bot = CRE::Notifiers::TelegramBot.new(
|
||||||
|
bus: bus,
|
||||||
|
telegram: telegram,
|
||||||
|
persistence: persist,
|
||||||
|
viewer_chats: [100_i64],
|
||||||
|
operator_chats: [200_i64],
|
||||||
|
)
|
||||||
|
{persist, bus, telegram, bot}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe CRE::Notifiers::TelegramBot do
|
||||||
|
it "viewer can run /status" do
|
||||||
|
persist, bus, _, bot = fresh_setup
|
||||||
|
reply = bot.handle_command(100_i64, "/status")
|
||||||
|
reply.should contain "live"
|
||||||
|
reply.should contain "Credentials"
|
||||||
|
ensure
|
||||||
|
bus.try(&.stop)
|
||||||
|
persist.try(&.close)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "viewer cannot /rotate" do
|
||||||
|
persist, bus, _, bot = fresh_setup
|
||||||
|
reply = bot.handle_command(100_i64, "/rotate 00000000-0000-0000-0000-000000000000")
|
||||||
|
reply.should contain "operator-only"
|
||||||
|
ensure
|
||||||
|
bus.try(&.stop)
|
||||||
|
persist.try(&.close)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "operator can /rotate; publishes RotationScheduled" do
|
||||||
|
persist, bus, _, bot = fresh_setup
|
||||||
|
|
||||||
|
received = [] of CRE::Events::Event
|
||||||
|
received_mutex = Mutex.new
|
||||||
|
ch = bus.subscribe
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
begin
|
||||||
|
ev = ch.receive
|
||||||
|
received_mutex.synchronize { received << ev }
|
||||||
|
rescue ::Channel::ClosedError
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
cred_id = UUID.random
|
||||||
|
reply = bot.handle_command(200_i64, "/rotate #{cred_id}")
|
||||||
|
reply.should contain "rotation scheduled"
|
||||||
|
sleep 0.1.seconds
|
||||||
|
received_mutex.synchronize { received.any?(&.is_a?(CRE::Events::RotationScheduled)).should be_true }
|
||||||
|
ensure
|
||||||
|
bus.try(&.stop)
|
||||||
|
persist.try(&.close)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unauthorized chat is blocked" do
|
||||||
|
persist, bus, _, bot = fresh_setup
|
||||||
|
bot.handle_command(999_i64, "/status").should eq "unauthorized"
|
||||||
|
ensure
|
||||||
|
bus.try(&.stop)
|
||||||
|
persist.try(&.close)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "/help lists commands" do
|
||||||
|
persist, bus, _, bot = fresh_setup
|
||||||
|
reply = bot.handle_command(100_i64, "/help")
|
||||||
|
reply.should contain "/status"
|
||||||
|
reply.should contain "/rotate"
|
||||||
|
ensure
|
||||||
|
bus.try(&.stop)
|
||||||
|
persist.try(&.close)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
# ===================
|
||||||
|
# ©AngelaMos | 2026
|
||||||
|
# telegram_spec.cr
|
||||||
|
# ===================
|
||||||
|
|
||||||
|
require "../../spec_helper"
|
||||||
|
require "webmock"
|
||||||
|
require "../../../src/cre/notifiers/telegram"
|
||||||
|
require "../../../src/cre/notifiers/telegram_subscriber"
|
||||||
|
require "../../../src/cre/events/credential_events"
|
||||||
|
|
||||||
|
WebMock.allow_net_connect = false
|
||||||
|
|
||||||
|
describe CRE::Notifiers::Telegram do
|
||||||
|
before_each { WebMock.reset }
|
||||||
|
|
||||||
|
it "sends a message" do
|
||||||
|
sent_body = nil
|
||||||
|
WebMock.stub(:post, "https://api.telegram.org/botFAKE/sendMessage")
|
||||||
|
.to_return { |req| sent_body = req.body.try(&.gets_to_end); HTTP::Client::Response.new(200, body: %({"ok":true})) }
|
||||||
|
CRE::Notifiers::Telegram.new("FAKE").send_message(12345_i64, "hello world")
|
||||||
|
sent_body.try(&.includes?("hello world")).should be_true
|
||||||
|
sent_body.try(&.includes?(%("chat_id":12345))).should be_true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "raises TelegramError on non-2xx" do
|
||||||
|
WebMock.stub(:post, "https://api.telegram.org/botFAKE/sendMessage")
|
||||||
|
.to_return(status: 401, body: %({"ok":false,"description":"Unauthorized"}))
|
||||||
|
expect_raises(CRE::Notifiers::Telegram::TelegramError) do
|
||||||
|
CRE::Notifiers::Telegram.new("FAKE").send_message(1_i64, "x")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "parses getUpdates with messages" do
|
||||||
|
WebMock.stub(:post, "https://api.telegram.org/botFAKE/getUpdates")
|
||||||
|
.to_return(body: %({
|
||||||
|
"ok":true,
|
||||||
|
"result":[{
|
||||||
|
"update_id":42,
|
||||||
|
"message":{
|
||||||
|
"message_id":7,
|
||||||
|
"chat":{"id":99},
|
||||||
|
"text":"/status"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
updates = CRE::Notifiers::Telegram.new("FAKE").get_updates
|
||||||
|
updates.size.should eq 1
|
||||||
|
updates[0].chat_id.should eq 99
|
||||||
|
updates[0].text.should eq "/status"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe CRE::Notifiers::TelegramSubscriber do
|
||||||
|
before_each { WebMock.reset }
|
||||||
|
|
||||||
|
it "fires Telegram message on RotationFailed" do
|
||||||
|
sent = [] of String
|
||||||
|
WebMock.stub(:post, "https://api.telegram.org/botFAKE/sendMessage")
|
||||||
|
.to_return { |req|
|
||||||
|
body = req.body.try(&.gets_to_end) || ""
|
||||||
|
sent << body
|
||||||
|
HTTP::Client::Response.new(200, body: %({"ok":true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
bus = CRE::Engine::EventBus.new
|
||||||
|
sub = CRE::Notifiers::TelegramSubscriber.new(
|
||||||
|
bus, CRE::Notifiers::Telegram.new("FAKE"), [12345_i64],
|
||||||
|
)
|
||||||
|
sub.start
|
||||||
|
bus.run
|
||||||
|
|
||||||
|
bus.publish CRE::Events::RotationFailed.new(UUID.random, UUID.random, "boom")
|
||||||
|
sleep 0.1.seconds
|
||||||
|
|
||||||
|
sent.size.should eq 1
|
||||||
|
sent[0].should contain "FAILED"
|
||||||
|
ensure
|
||||||
|
bus.try(&.stop)
|
||||||
|
sub.try(&.stop)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not fire on RotationCompleted unless notify_on_success" do
|
||||||
|
sent = [] of String
|
||||||
|
WebMock.stub(:post, "https://api.telegram.org/botFAKE/sendMessage")
|
||||||
|
.to_return { |req| sent << (req.body.try(&.gets_to_end) || ""); HTTP::Client::Response.new(200, body: %({"ok":true})) }
|
||||||
|
|
||||||
|
bus = CRE::Engine::EventBus.new
|
||||||
|
sub = CRE::Notifiers::TelegramSubscriber.new(
|
||||||
|
bus, CRE::Notifiers::Telegram.new("FAKE"), [1_i64], notify_on_success: false,
|
||||||
|
)
|
||||||
|
sub.start
|
||||||
|
bus.run
|
||||||
|
bus.publish CRE::Events::RotationCompleted.new(UUID.random, UUID.random)
|
||||||
|
sleep 0.1.seconds
|
||||||
|
sent.size.should eq 0
|
||||||
|
ensure
|
||||||
|
bus.try(&.stop)
|
||||||
|
sub.try(&.stop)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# ===================
|
||||||
|
# ©AngelaMos | 2026
|
||||||
|
# log_notifier.cr
|
||||||
|
# ===================
|
||||||
|
|
||||||
|
require "log"
|
||||||
|
require "../engine/event_bus"
|
||||||
|
require "../events/credential_events"
|
||||||
|
require "../events/system_events"
|
||||||
|
|
||||||
|
module CRE::Notifiers
|
||||||
|
# LogNotifier subscribes to all events and emits structured stdlib Log lines.
|
||||||
|
# Suitable for shipping to journald, vector, or fluentd; downstream tooling
|
||||||
|
# can ingest the structured fields directly.
|
||||||
|
class LogNotifier
|
||||||
|
Log = ::Log.for("cre.notifier")
|
||||||
|
|
||||||
|
@ch : ::Channel(Events::Event)?
|
||||||
|
@running : Bool
|
||||||
|
|
||||||
|
def initialize(@bus : Engine::EventBus)
|
||||||
|
@running = false
|
||||||
|
end
|
||||||
|
|
||||||
|
def start : Nil
|
||||||
|
@running = true
|
||||||
|
ch = @bus.subscribe(buffer: 64, overflow: Engine::EventBus::Overflow::Drop)
|
||||||
|
@ch = ch
|
||||||
|
spawn(name: "log-notifier") do
|
||||||
|
while @running
|
||||||
|
begin
|
||||||
|
ev = ch.receive
|
||||||
|
rescue ::Channel::ClosedError
|
||||||
|
break
|
||||||
|
end
|
||||||
|
emit(ev)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def stop : Nil
|
||||||
|
@running = false
|
||||||
|
@ch.try(&.close)
|
||||||
|
end
|
||||||
|
|
||||||
|
private def emit(ev : Events::Event) : Nil
|
||||||
|
case ev
|
||||||
|
when Events::RotationCompleted
|
||||||
|
Log.info &.emit("rotation completed", credential_id: ev.credential_id.to_s, rotation_id: ev.rotation_id.to_s)
|
||||||
|
when Events::RotationFailed
|
||||||
|
Log.error &.emit("rotation failed", credential_id: ev.credential_id.to_s, rotation_id: ev.rotation_id.to_s, reason: ev.reason)
|
||||||
|
when Events::PolicyViolation
|
||||||
|
Log.warn &.emit("policy violation", credential_id: ev.credential_id.to_s, policy: ev.policy_name, reason: ev.reason)
|
||||||
|
when Events::DriftDetected
|
||||||
|
Log.warn &.emit("drift detected", credential_id: ev.credential_id.to_s, expected: ev.expected_hash, actual: ev.actual_hash)
|
||||||
|
when Events::AlertRaised
|
||||||
|
case ev.severity
|
||||||
|
in Events::Severity::Critical then Log.error &.emit("alert", text: ev.message)
|
||||||
|
in Events::Severity::Warn then Log.warn &.emit("alert", text: ev.message)
|
||||||
|
in Events::Severity::Info then Log.info &.emit("alert", text: ev.message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# ===================
|
||||||
|
# ©AngelaMos | 2026
|
||||||
|
# telegram.cr
|
||||||
|
# ===================
|
||||||
|
|
||||||
|
require "http/client"
|
||||||
|
require "json"
|
||||||
|
require "log"
|
||||||
|
|
||||||
|
module CRE::Notifiers
|
||||||
|
# Thin Telegram Bot API client. We hit api.telegram.org/bot<TOKEN>/<METHOD>
|
||||||
|
# directly with HTTP::Client; no tourmaline dependency for the notification
|
||||||
|
# path keeps the footprint small.
|
||||||
|
class Telegram
|
||||||
|
Log = ::Log.for("cre.telegram")
|
||||||
|
DEFAULT_API = "https://api.telegram.org"
|
||||||
|
|
||||||
|
class TelegramError < Exception
|
||||||
|
getter status : Int32
|
||||||
|
|
||||||
|
def initialize(message : String, @status : Int32)
|
||||||
|
super(message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
record Update,
|
||||||
|
update_id : Int64,
|
||||||
|
message_id : Int64?,
|
||||||
|
chat_id : Int64?,
|
||||||
|
text : String?
|
||||||
|
|
||||||
|
def initialize(@token : String, @api_base : String = DEFAULT_API)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_message(chat_id : Int64, text : String, parse_mode : String? = nil) : Nil
|
||||||
|
payload = {"chat_id" => chat_id, "text" => text} of String => String | Int64
|
||||||
|
payload["parse_mode"] = parse_mode if parse_mode
|
||||||
|
call("sendMessage", payload.to_json)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_updates(offset : Int64? = nil, timeout : Int32 = 30) : Array(Update)
|
||||||
|
h = {"timeout" => timeout} of String => String | Int64 | Int32
|
||||||
|
h["offset"] = offset if offset
|
||||||
|
json = call("getUpdates", h.to_json)
|
||||||
|
results = json["result"].as_a
|
||||||
|
results.map do |entry|
|
||||||
|
msg = entry["message"]?
|
||||||
|
Update.new(
|
||||||
|
update_id: entry["update_id"].as_i64,
|
||||||
|
message_id: msg.try(&.["message_id"]?.try(&.as_i64)),
|
||||||
|
chat_id: msg.try(&.["chat"]?.try(&.["id"]?.try(&.as_i64))),
|
||||||
|
text: msg.try(&.["text"]?.try(&.as_s)),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def call(method : String, body : String) : JSON::Any
|
||||||
|
uri = "#{@api_base}/bot#{@token}/#{method}"
|
||||||
|
headers = HTTP::Headers{"Content-Type" => "application/json"}
|
||||||
|
response = HTTP::Client.post(uri, headers: headers, body: body)
|
||||||
|
raise TelegramError.new("telegram #{method} #{response.status_code}: #{response.body[0, 200]?}", response.status_code) unless response.status_code < 300
|
||||||
|
JSON.parse(response.body)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
# ===================
|
||||||
|
# ©AngelaMos | 2026
|
||||||
|
# telegram_bot.cr
|
||||||
|
# ===================
|
||||||
|
|
||||||
|
require "log"
|
||||||
|
require "./telegram"
|
||||||
|
require "../engine/event_bus"
|
||||||
|
require "../persistence/persistence"
|
||||||
|
|
||||||
|
module CRE::Notifiers
|
||||||
|
# TelegramBot does long-polling getUpdates and dispatches commands to
|
||||||
|
# operator handlers. Two ACL tiers:
|
||||||
|
# - viewer : read-only commands (/status, /queue, /history, /alerts, /help)
|
||||||
|
# - operator: viewer + mutating commands (/rotate, /snooze)
|
||||||
|
#
|
||||||
|
# Authorization is by chat_id; not strictly authentication but adequate for a
|
||||||
|
# single-tenant deployment where the bot token + chat IDs live in env vars.
|
||||||
|
class TelegramBot
|
||||||
|
Log = ::Log.for("cre.telegram_bot")
|
||||||
|
|
||||||
|
@running : Bool
|
||||||
|
@last_offset : Int64
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
@bus : Engine::EventBus,
|
||||||
|
@telegram : Telegram,
|
||||||
|
@persistence : Persistence::Persistence,
|
||||||
|
@viewer_chats : Array(Int64),
|
||||||
|
@operator_chats : Array(Int64),
|
||||||
|
)
|
||||||
|
@running = false
|
||||||
|
@last_offset = 0_i64
|
||||||
|
end
|
||||||
|
|
||||||
|
def start : Nil
|
||||||
|
@running = true
|
||||||
|
spawn(name: "telegram-bot") do
|
||||||
|
while @running
|
||||||
|
begin
|
||||||
|
poll_once
|
||||||
|
rescue ex
|
||||||
|
Log.error(exception: ex) { "telegram bot poll failed" }
|
||||||
|
sleep 1.second
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def stop : Nil
|
||||||
|
@running = false
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorized_viewer?(chat_id : Int64) : Bool
|
||||||
|
@viewer_chats.includes?(chat_id) || @operator_chats.includes?(chat_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorized_operator?(chat_id : Int64) : Bool
|
||||||
|
@operator_chats.includes?(chat_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_command(chat_id : Int64, text : String) : String
|
||||||
|
return "unauthorized" unless authorized_viewer?(chat_id)
|
||||||
|
cmd, _, rest = text.strip.lstrip('/').partition(' ')
|
||||||
|
case cmd
|
||||||
|
when "status" then status_message
|
||||||
|
when "queue" then queue_message
|
||||||
|
when "alerts" then alerts_message
|
||||||
|
when "help", "" then help_message
|
||||||
|
when "rotate" then handle_rotate(chat_id, rest)
|
||||||
|
when "snooze" then handle_snooze(chat_id, rest)
|
||||||
|
when "history" then history_message(rest)
|
||||||
|
else
|
||||||
|
"unknown command: /#{cmd} (try /help)"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def poll_once : Nil
|
||||||
|
updates = @telegram.get_updates(offset: @last_offset == 0 ? nil : @last_offset, timeout: 5)
|
||||||
|
updates.each do |u|
|
||||||
|
@last_offset = u.update_id + 1
|
||||||
|
chat_id = u.chat_id
|
||||||
|
text = u.text
|
||||||
|
next if chat_id.nil? || text.nil? || !text.starts_with?('/')
|
||||||
|
reply = handle_command(chat_id, text)
|
||||||
|
@telegram.send_message(chat_id, reply) rescue nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def status_message : String
|
||||||
|
total = @persistence.credentials.all.size
|
||||||
|
in_flight = @persistence.rotations.in_flight.size
|
||||||
|
"● live\nCredentials: #{total}\nIn-flight rotations: #{in_flight}"
|
||||||
|
end
|
||||||
|
|
||||||
|
private def queue_message : String
|
||||||
|
in_flight = @persistence.rotations.in_flight
|
||||||
|
return "queue empty" if in_flight.empty?
|
||||||
|
lines = in_flight.first(10).map { |r| "- #{r.rotator_kind} #{r.credential_id} [#{r.state}]" }
|
||||||
|
"Active queue (#{in_flight.size}):\n#{lines.join('\n')}"
|
||||||
|
end
|
||||||
|
|
||||||
|
private def alerts_message : String
|
||||||
|
"Alerts inspection is exposed via the audit log; use 'cre audit verify --since=...' from the CLI."
|
||||||
|
end
|
||||||
|
|
||||||
|
private def help_message : String
|
||||||
|
<<-MD
|
||||||
|
Available commands:
|
||||||
|
/status - quick health summary
|
||||||
|
/queue - active rotations
|
||||||
|
/history <id> - last events for a credential
|
||||||
|
/alerts - critical alerts pointer
|
||||||
|
/rotate <id> - force rotation (operator)
|
||||||
|
/snooze <id> 24h - defer scheduled rotation (operator)
|
||||||
|
MD
|
||||||
|
end
|
||||||
|
|
||||||
|
private def handle_rotate(chat_id : Int64, rest : String) : String
|
||||||
|
return "operator-only command" unless authorized_operator?(chat_id)
|
||||||
|
id_str = rest.strip
|
||||||
|
return "usage: /rotate <credential-id>" if id_str.empty?
|
||||||
|
uuid = UUID.new(id_str) rescue nil
|
||||||
|
return "invalid credential id" if uuid.nil?
|
||||||
|
@bus.publish Events::RotationScheduled.new(uuid, "manual")
|
||||||
|
"rotation scheduled for #{uuid}"
|
||||||
|
end
|
||||||
|
|
||||||
|
private def handle_snooze(chat_id : Int64, rest : String) : String
|
||||||
|
return "operator-only command" unless authorized_operator?(chat_id)
|
||||||
|
"snooze is not yet implemented; track via /queue"
|
||||||
|
end
|
||||||
|
|
||||||
|
private def history_message(rest : String) : String
|
||||||
|
id_str = rest.strip
|
||||||
|
return "usage: /history <credential-id>" if id_str.empty?
|
||||||
|
uuid = UUID.new(id_str) rescue nil
|
||||||
|
return "invalid credential id" if uuid.nil?
|
||||||
|
cred = @persistence.credentials.find(uuid)
|
||||||
|
return "credential not found" if cred.nil?
|
||||||
|
latest = @persistence.audit.latest_seq
|
||||||
|
entries = latest > 10 ? @persistence.audit.range(latest - 9, latest) : @persistence.audit.range(1_i64, latest)
|
||||||
|
filtered = entries.select { |e| e.target_id == uuid }
|
||||||
|
return "no audit entries for #{uuid}" if filtered.empty?
|
||||||
|
lines = filtered.last(10).map { |e| "- #{e.occurred_at.to_rfc3339}: #{e.event_type}" }
|
||||||
|
"Last events for #{cred.name}:\n#{lines.join('\n')}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
# ===================
|
||||||
|
# ©AngelaMos | 2026
|
||||||
|
# telegram_subscriber.cr
|
||||||
|
# ===================
|
||||||
|
|
||||||
|
require "log"
|
||||||
|
require "./telegram"
|
||||||
|
require "../engine/event_bus"
|
||||||
|
require "../events/credential_events"
|
||||||
|
require "../events/system_events"
|
||||||
|
|
||||||
|
module CRE::Notifiers
|
||||||
|
# TelegramSubscriber sends emoji-prefixed messages to allowlisted chats on
|
||||||
|
# significant events. Best-effort delivery (Drop overflow); transient
|
||||||
|
# Telegram errors are logged and swallowed so a network blip never blocks
|
||||||
|
# the engine.
|
||||||
|
class TelegramSubscriber
|
||||||
|
Log = ::Log.for("cre.telegram_subscriber")
|
||||||
|
|
||||||
|
@ch : ::Channel(Events::Event)?
|
||||||
|
@running : Bool
|
||||||
|
|
||||||
|
def initialize(@bus : Engine::EventBus, @telegram : Telegram, @viewer_chats : Array(Int64), @notify_on_success : Bool = false)
|
||||||
|
@running = false
|
||||||
|
end
|
||||||
|
|
||||||
|
def start : Nil
|
||||||
|
@running = true
|
||||||
|
ch = @bus.subscribe(buffer: 128, overflow: Engine::EventBus::Overflow::Drop)
|
||||||
|
@ch = ch
|
||||||
|
spawn(name: "telegram-sub") do
|
||||||
|
while @running
|
||||||
|
begin
|
||||||
|
ev = ch.receive
|
||||||
|
rescue ::Channel::ClosedError
|
||||||
|
break
|
||||||
|
end
|
||||||
|
dispatch(ev)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def stop : Nil
|
||||||
|
@running = false
|
||||||
|
@ch.try(&.close)
|
||||||
|
end
|
||||||
|
|
||||||
|
private def dispatch(ev : Events::Event) : Nil
|
||||||
|
msg = format(ev)
|
||||||
|
return if msg.nil?
|
||||||
|
@viewer_chats.each do |chat|
|
||||||
|
@telegram.send_message(chat, msg)
|
||||||
|
rescue ex
|
||||||
|
Log.warn(exception: ex) { "telegram send failed for chat=#{chat}" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def format(ev : Events::Event) : String?
|
||||||
|
case ev
|
||||||
|
when Events::RotationFailed
|
||||||
|
"! Rotation FAILED for credential #{ev.credential_id} (rotation #{ev.rotation_id}): #{ev.reason}"
|
||||||
|
when Events::DriftDetected
|
||||||
|
"⚠ Drift detected on credential #{ev.credential_id}: hash mismatch (expected=#{ev.expected_hash[0, 12]}..., actual=#{ev.actual_hash[0, 12]}...)"
|
||||||
|
when Events::PolicyViolation
|
||||||
|
"⚠ Policy violation: '#{ev.policy_name}' on #{ev.credential_id} (#{ev.reason})"
|
||||||
|
when Events::AlertRaised
|
||||||
|
sev = case ev.severity
|
||||||
|
in Events::Severity::Critical then "!"
|
||||||
|
in Events::Severity::Warn then "⚠"
|
||||||
|
in Events::Severity::Info then "ℹ"
|
||||||
|
end
|
||||||
|
"#{sev} #{ev.message}"
|
||||||
|
when Events::RotationCompleted
|
||||||
|
@notify_on_success ? "✓ Rotation completed for credential #{ev.credential_id}" : nil
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue