pocketrb 0.1.0

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 (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +456 -0
  5. data/exe/pocketrb +6 -0
  6. data/lib/pocketrb/agent/compaction.rb +187 -0
  7. data/lib/pocketrb/agent/context.rb +171 -0
  8. data/lib/pocketrb/agent/loop.rb +276 -0
  9. data/lib/pocketrb/agent/spawn_tool.rb +72 -0
  10. data/lib/pocketrb/agent/subagent_manager.rb +196 -0
  11. data/lib/pocketrb/bus/events.rb +99 -0
  12. data/lib/pocketrb/bus/message_bus.rb +148 -0
  13. data/lib/pocketrb/channels/base.rb +69 -0
  14. data/lib/pocketrb/channels/cli.rb +109 -0
  15. data/lib/pocketrb/channels/telegram.rb +607 -0
  16. data/lib/pocketrb/channels/whatsapp.rb +242 -0
  17. data/lib/pocketrb/cli/base.rb +119 -0
  18. data/lib/pocketrb/cli/chat.rb +67 -0
  19. data/lib/pocketrb/cli/config.rb +52 -0
  20. data/lib/pocketrb/cli/cron.rb +144 -0
  21. data/lib/pocketrb/cli/gateway.rb +132 -0
  22. data/lib/pocketrb/cli/init.rb +39 -0
  23. data/lib/pocketrb/cli/plans.rb +28 -0
  24. data/lib/pocketrb/cli/skills.rb +34 -0
  25. data/lib/pocketrb/cli/start.rb +55 -0
  26. data/lib/pocketrb/cli/telegram.rb +93 -0
  27. data/lib/pocketrb/cli/version.rb +18 -0
  28. data/lib/pocketrb/cli/whatsapp.rb +60 -0
  29. data/lib/pocketrb/cli.rb +124 -0
  30. data/lib/pocketrb/config.rb +190 -0
  31. data/lib/pocketrb/cron/job.rb +155 -0
  32. data/lib/pocketrb/cron/service.rb +395 -0
  33. data/lib/pocketrb/heartbeat/service.rb +175 -0
  34. data/lib/pocketrb/mcp/client.rb +172 -0
  35. data/lib/pocketrb/mcp/memory_tool.rb +133 -0
  36. data/lib/pocketrb/media/processor.rb +258 -0
  37. data/lib/pocketrb/memory.rb +283 -0
  38. data/lib/pocketrb/planning/manager.rb +159 -0
  39. data/lib/pocketrb/planning/plan.rb +223 -0
  40. data/lib/pocketrb/planning/tool.rb +176 -0
  41. data/lib/pocketrb/providers/anthropic.rb +333 -0
  42. data/lib/pocketrb/providers/base.rb +98 -0
  43. data/lib/pocketrb/providers/claude_cli.rb +412 -0
  44. data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
  45. data/lib/pocketrb/providers/openrouter.rb +205 -0
  46. data/lib/pocketrb/providers/registry.rb +59 -0
  47. data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
  48. data/lib/pocketrb/providers/types.rb +111 -0
  49. data/lib/pocketrb/session/manager.rb +192 -0
  50. data/lib/pocketrb/session/session.rb +204 -0
  51. data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
  52. data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
  53. data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
  54. data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
  55. data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
  56. data/lib/pocketrb/skills/create_tool.rb +115 -0
  57. data/lib/pocketrb/skills/loader.rb +164 -0
  58. data/lib/pocketrb/skills/modify_tool.rb +123 -0
  59. data/lib/pocketrb/skills/skill.rb +75 -0
  60. data/lib/pocketrb/tools/background_job_manager.rb +261 -0
  61. data/lib/pocketrb/tools/base.rb +118 -0
  62. data/lib/pocketrb/tools/browser.rb +152 -0
  63. data/lib/pocketrb/tools/browser_advanced.rb +470 -0
  64. data/lib/pocketrb/tools/browser_session.rb +167 -0
  65. data/lib/pocketrb/tools/cron.rb +222 -0
  66. data/lib/pocketrb/tools/edit_file.rb +101 -0
  67. data/lib/pocketrb/tools/exec.rb +194 -0
  68. data/lib/pocketrb/tools/jobs.rb +127 -0
  69. data/lib/pocketrb/tools/list_dir.rb +102 -0
  70. data/lib/pocketrb/tools/memory.rb +167 -0
  71. data/lib/pocketrb/tools/message.rb +70 -0
  72. data/lib/pocketrb/tools/para_memory.rb +264 -0
  73. data/lib/pocketrb/tools/read_file.rb +65 -0
  74. data/lib/pocketrb/tools/registry.rb +160 -0
  75. data/lib/pocketrb/tools/send_file.rb +158 -0
  76. data/lib/pocketrb/tools/think.rb +35 -0
  77. data/lib/pocketrb/tools/web_fetch.rb +150 -0
  78. data/lib/pocketrb/tools/web_search.rb +102 -0
  79. data/lib/pocketrb/tools/write_file.rb +55 -0
  80. data/lib/pocketrb/version.rb +5 -0
  81. data/lib/pocketrb.rb +75 -0
  82. data/pocketrb.gemspec +60 -0
  83. metadata +327 -0
@@ -0,0 +1,607 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "telegram/bot"
4
+
5
+ module Pocketrb
6
+ module Channels
7
+ # Telegram channel using long polling
8
+ # Simple and reliable - no webhook/public IP needed
9
+ class Telegram < Base
10
+ MARKDOWN_TO_HTML = {
11
+ # Bold **text** or __text__
12
+ /\*\*(.+?)\*\*/ => '<b>\1</b>',
13
+ /__(.+?)__/ => '<b>\1</b>',
14
+ # Italic _text_ (avoid matching inside words)
15
+ /(?<![a-zA-Z0-9])_([^_]+)_(?![a-zA-Z0-9])/ => '<i>\1</i>',
16
+ # Strikethrough ~~text~~
17
+ /~~(.+?)~~/ => '<s>\1</s>',
18
+ # Inline code `text`
19
+ /`([^`]+)`/ => '<code>\1</code>'
20
+ }.freeze
21
+
22
+ TELEGRAM_FILE_URL = "https://api.telegram.org/file/bot%<token>s/%<path>s"
23
+
24
+ # Special commands that bypass the agent
25
+ SPECIAL_COMMANDS = %w[/status /jobs /cron /help].freeze
26
+
27
+ attr_accessor :status_context
28
+
29
+ def initialize(bus:, token:, allowed_users: nil, download_media: true)
30
+ super(bus: bus, name: :telegram)
31
+ @token = token
32
+ @allowed_users = allowed_users # Array of usernames or user IDs, nil = allow all
33
+ @download_media = download_media
34
+ @bot = nil
35
+ @chat_ids = {} # Map sender_id to chat_id for replies
36
+ @media_processor = Media::Processor.new
37
+ @status_context = {} # Will hold job_manager, cron_service, etc.
38
+ @started_at = Time.now
39
+ end
40
+
41
+ protected
42
+
43
+ def run_inbound_loop
44
+ Pocketrb.logger.info("Starting Telegram bot (polling mode)...")
45
+
46
+ # Run the blocking telegram listener in a separate thread
47
+ # so Async outbound consumer can process messages
48
+ @listener_thread = Thread.new do
49
+ ::Telegram::Bot::Client.run(@token) do |bot|
50
+ @bot = bot
51
+
52
+ # Get bot info
53
+ me = bot.api.get_me
54
+ username = me.respond_to?(:username) ? me.username : me.dig("result", "username")
55
+ Pocketrb.logger.info("Telegram bot @#{username} connected")
56
+
57
+ bot.listen do |message|
58
+ break unless @running
59
+
60
+ handle_telegram_message(message)
61
+ end
62
+ end
63
+ rescue StandardError => e
64
+ Pocketrb.logger.error("Telegram listener error: #{e.message}")
65
+ end
66
+
67
+ # Keep the main fiber alive for async tasks
68
+ sleep 0.1 while @running
69
+
70
+ @listener_thread&.join
71
+ rescue StandardError => e
72
+ Pocketrb.logger.error("Telegram error: #{e.message}")
73
+ raise
74
+ end
75
+
76
+ def send_message(message)
77
+ return unless @bot
78
+
79
+ chat_id = message.chat_id.to_i
80
+
81
+ # Send media attachments first
82
+ message.media&.each do |media|
83
+ send_media(chat_id, media)
84
+ end
85
+
86
+ # Send text content if present
87
+ return if message.content.nil? || message.content.empty?
88
+
89
+ html_content = markdown_to_telegram_html(message.content)
90
+
91
+ @bot.api.send_message(
92
+ chat_id: chat_id,
93
+ text: html_content,
94
+ parse_mode: "HTML",
95
+ reply_to_message_id: message.reply_to
96
+ )
97
+ rescue StandardError => e
98
+ Pocketrb.logger.warn("HTML parse failed, falling back to plain text: #{e.message}")
99
+ begin
100
+ @bot.api.send_message(
101
+ chat_id: chat_id,
102
+ text: message.content,
103
+ reply_to_message_id: message.reply_to
104
+ )
105
+ rescue StandardError => e2
106
+ Pocketrb.logger.error("Error sending Telegram message: #{e2.message}")
107
+ end
108
+ end
109
+
110
+ def send_media(chat_id, media)
111
+ case media.type
112
+ when :image
113
+ send_photo(chat_id, media)
114
+ when :audio
115
+ send_audio(chat_id, media)
116
+ when :video
117
+ send_video(chat_id, media)
118
+ else
119
+ send_document(chat_id, media)
120
+ end
121
+ rescue StandardError => e
122
+ Pocketrb.logger.error("Error sending media: #{e.message}")
123
+ end
124
+
125
+ def send_photo(chat_id, media)
126
+ file = Faraday::Multipart::FilePart.new(media.path, media.mime_type, media.filename)
127
+ @bot.api.send_photo(chat_id: chat_id, photo: file)
128
+ end
129
+
130
+ def send_audio(chat_id, media)
131
+ file = Faraday::Multipart::FilePart.new(media.path, media.mime_type, media.filename)
132
+ @bot.api.send_audio(chat_id: chat_id, audio: file)
133
+ end
134
+
135
+ def send_video(chat_id, media)
136
+ file = Faraday::Multipart::FilePart.new(media.path, media.mime_type, media.filename)
137
+ @bot.api.send_video(chat_id: chat_id, video: file)
138
+ end
139
+
140
+ def send_document(chat_id, media)
141
+ file = Faraday::Multipart::FilePart.new(media.path, media.mime_type, media.filename)
142
+ @bot.api.send_document(chat_id: chat_id, document: file)
143
+ end
144
+
145
+ private
146
+
147
+ def handle_telegram_message(message)
148
+ return unless message.is_a?(::Telegram::Bot::Types::Message)
149
+ unless message.text || message.caption || message.photo || message.voice || message.document || message.audio || message.video
150
+ return
151
+ end
152
+
153
+ user = message.from
154
+ return unless user
155
+
156
+ # Check allowlist
157
+ if @allowed_users && !allowed_user?(user)
158
+ Pocketrb.logger.debug("Ignoring message from non-allowed user: #{user.username || user.id}")
159
+ return
160
+ end
161
+
162
+ chat_id = message.chat.id
163
+ sender_id = build_sender_id(user)
164
+
165
+ # Store chat_id for replies
166
+ @chat_ids[sender_id] = chat_id
167
+
168
+ # Handle special commands (bypass agent)
169
+ return if message.text && handle_special_command(message.text, chat_id)
170
+
171
+ # Build content and download media
172
+ content = build_content(message)
173
+ media = download_media(message)
174
+
175
+ Pocketrb.logger.debug("Telegram message from #{sender_id}: #{content[0..50]}... (#{media.size} media)")
176
+
177
+ # Create and publish inbound message
178
+ inbound = create_inbound_message(
179
+ sender_id: sender_id,
180
+ chat_id: chat_id.to_s,
181
+ content: content,
182
+ media: media,
183
+ metadata: {
184
+ message_id: message.message_id,
185
+ user_id: user.id,
186
+ username: user.username,
187
+ first_name: user.first_name,
188
+ is_group: message.chat.type != "private"
189
+ }
190
+ )
191
+
192
+ @bus.publish_inbound(inbound)
193
+ end
194
+
195
+ def build_sender_id(user)
196
+ if user.username
197
+ "#{user.id}|#{user.username}"
198
+ else
199
+ user.id.to_s
200
+ end
201
+ end
202
+
203
+ def build_content(message)
204
+ parts = []
205
+ parts << message.text if message.text
206
+ parts << message.caption if message.caption
207
+
208
+ # Add descriptive text for media (actual media is in media array)
209
+ parts << "[Image attached - I can see this image]" if message.photo&.any? && @download_media
210
+
211
+ parts << "[Voice message attached]" if message.voice
212
+
213
+ parts << "[Audio: #{message.audio.title || message.audio.file_name || "audio"}]" if message.audio
214
+
215
+ parts << "[Video attached]" if message.video
216
+
217
+ parts << "[Document: #{message.document.file_name}]" if message.document
218
+
219
+ parts.empty? ? "[empty message]" : parts.join("\n")
220
+ end
221
+
222
+ def download_media(message)
223
+ return [] unless @download_media
224
+
225
+ media = []
226
+
227
+ # Download photos (get largest size)
228
+ if message.photo&.any?
229
+ photo = message.photo.max_by(&:file_size)
230
+ media_item = download_telegram_file(photo.file_id, :image, "image/jpeg")
231
+ media << media_item if media_item
232
+ end
233
+
234
+ # Download voice messages
235
+ if message.voice
236
+ media_item = download_telegram_file(
237
+ message.voice.file_id,
238
+ :audio,
239
+ message.voice.mime_type || "audio/ogg"
240
+ )
241
+ media << media_item if media_item
242
+ end
243
+
244
+ # Download audio files
245
+ if message.audio
246
+ media_item = download_telegram_file(
247
+ message.audio.file_id,
248
+ :audio,
249
+ message.audio.mime_type || "audio/mpeg",
250
+ message.audio.file_name
251
+ )
252
+ media << media_item if media_item
253
+ end
254
+
255
+ # Download videos
256
+ if message.video
257
+ media_item = download_telegram_file(
258
+ message.video.file_id,
259
+ :video,
260
+ message.video.mime_type || "video/mp4",
261
+ message.video.file_name
262
+ )
263
+ media << media_item if media_item
264
+ end
265
+
266
+ # Download documents
267
+ if message.document
268
+ media_item = download_telegram_file(
269
+ message.document.file_id,
270
+ :file,
271
+ message.document.mime_type || "application/octet-stream",
272
+ message.document.file_name
273
+ )
274
+ media << media_item if media_item
275
+ end
276
+
277
+ media
278
+ end
279
+
280
+ def download_telegram_file(file_id, _type, mime_type, filename = nil)
281
+ # Get file path from Telegram
282
+ result = @bot.api.get_file(file_id: file_id)
283
+
284
+ # Handle both telegram-bot-ruby v1 (hash) and v2 (typed object)
285
+ file_path = if result.respond_to?(:file_path)
286
+ result.file_path
287
+ else
288
+ result.dig("result", "file_path")
289
+ end
290
+ return nil unless file_path
291
+
292
+ # Build download URL
293
+ url = format(TELEGRAM_FILE_URL, token: @token, path: file_path)
294
+
295
+ # Download and process
296
+ filename ||= File.basename(file_path)
297
+ @media_processor.download(url, filename: filename, mime_type: mime_type)
298
+ rescue StandardError => e
299
+ Pocketrb.logger.warn("Failed to download Telegram file #{file_id}: #{e.message}")
300
+ nil
301
+ end
302
+
303
+ def allowed_user?(user)
304
+ return true if @allowed_users.nil? || @allowed_users.empty?
305
+
306
+ @allowed_users.any? do |allowed|
307
+ allowed.to_s == user.id.to_s ||
308
+ allowed.to_s.downcase == user.username&.downcase
309
+ end
310
+ end
311
+
312
+ # Handle special commands that bypass the agent
313
+ def handle_special_command(text, chat_id)
314
+ cmd = text.strip.split.first&.downcase
315
+ return false unless SPECIAL_COMMANDS.include?(cmd)
316
+
317
+ response = case cmd
318
+ when "/status" then build_status_response
319
+ when "/jobs" then build_jobs_response
320
+ when "/cron" then build_cron_response
321
+ when "/help" then build_help_response
322
+ else return false
323
+ end
324
+
325
+ @bot.api.send_message(
326
+ chat_id: chat_id,
327
+ text: response,
328
+ parse_mode: "HTML"
329
+ )
330
+ true
331
+ rescue StandardError => e
332
+ Pocketrb.logger.error("Special command error: #{e.message}")
333
+ @bot.api.send_message(chat_id: chat_id, text: "Error: #{e.message}")
334
+ true
335
+ end
336
+
337
+ def build_status_response
338
+ lines = []
339
+ lines << "━━━━━━━━━━━━━━━━━━━━━━━━━━"
340
+ lines << "🤖 <b>Pocketrb Status</b>"
341
+ lines << "━━━━━━━━━━━━━━━━━━━━━━━━━━"
342
+
343
+ # Uptime
344
+ uptime = format_uptime(Time.now - @started_at)
345
+ lines << "⏱ Uptime: #{uptime}"
346
+
347
+ # Provider info
348
+ if @status_context[:provider]
349
+ provider = @status_context[:provider]
350
+ lines << "🔌 Provider: #{provider.class.name.split("::").last}"
351
+ lines << "🧠 Model: #{@status_context[:model] || "default"}"
352
+ end
353
+
354
+ # Claude CLI status (if using claude_cli provider)
355
+ claude_status = get_claude_cli_status
356
+ if claude_status
357
+ lines << ""
358
+ lines << "🖥 <b>Claude CLI:</b>"
359
+ claude_status.each { |line| lines << " #{line}" }
360
+ end
361
+
362
+ # Background jobs
363
+ jobs_info = get_jobs_summary
364
+ lines << ""
365
+ lines << "📋 <b>Background Jobs:</b> #{jobs_info[:summary]}"
366
+ jobs_info[:jobs].first(3).each { |j| lines << " • #{j}" }
367
+ lines << " ... and #{jobs_info[:jobs].length - 3} more" if jobs_info[:jobs].length > 3
368
+
369
+ # Cron jobs
370
+ cron_info = get_cron_summary
371
+ lines << ""
372
+ lines << "⏰ <b>Scheduled Jobs:</b> #{cron_info[:summary]}"
373
+ cron_info[:jobs].first(3).each { |j| lines << " • #{j}" }
374
+ lines << " ... and #{cron_info[:jobs].length - 3} more" if cron_info[:jobs].length > 3
375
+
376
+ # Session info
377
+ if @status_context[:sessions]
378
+ session_count = begin
379
+ @status_context[:sessions].list_sessions.length
380
+ rescue StandardError
381
+ 0
382
+ end
383
+ lines << ""
384
+ lines << "💬 Sessions: #{session_count}"
385
+ end
386
+
387
+ lines << "━━━━━━━━━━━━━━━━━━━━━━━━━━"
388
+ lines.join("\n")
389
+ end
390
+
391
+ def build_jobs_response
392
+ jobs_info = get_jobs_summary
393
+ return "No background jobs." if jobs_info[:jobs].empty?
394
+
395
+ lines = ["<b>📋 Background Jobs</b>", ""]
396
+
397
+ running = jobs_info[:all].select { |j| j[:running] }
398
+ completed = jobs_info[:all].reject { |j| j[:running] }
399
+
400
+ if running.any?
401
+ lines << "<b>Running:</b>"
402
+ running.each do |job|
403
+ lines << " 🟢 [#{job[:job_id]}] #{job[:name]}"
404
+ end
405
+ lines << ""
406
+ end
407
+
408
+ if completed.any?
409
+ lines << "<b>Completed:</b>"
410
+ completed.first(10).each do |job|
411
+ lines << " ⚪ [#{job[:job_id]}] #{job[:name]}"
412
+ end
413
+ end
414
+
415
+ lines.join("\n")
416
+ end
417
+
418
+ def build_cron_response
419
+ cron_info = get_cron_summary
420
+ return "No scheduled jobs." if cron_info[:all].empty?
421
+
422
+ lines = ["<b>⏰ Scheduled Jobs</b>", ""]
423
+
424
+ cron_info[:all].each do |job|
425
+ status = job.enabled ? "🟢" : "⚪"
426
+ next_run = if job.state.next_run_at_ms
427
+ Time.at(job.state.next_run_at_ms / 1000).strftime("%m/%d %H:%M")
428
+ else
429
+ "—"
430
+ end
431
+ lines << "#{status} <b>#{job.name}</b> [#{job.id}]"
432
+ lines << " Next: #{next_run}"
433
+ lines << " #{job.payload.message[0..40]}#{"..." if job.payload.message.length > 40}"
434
+ lines << ""
435
+ end
436
+
437
+ lines.join("\n")
438
+ end
439
+
440
+ def build_help_response
441
+ <<~HELP
442
+ <b>🤖 Pocketrb Commands</b>
443
+
444
+ <b>/status</b> - Show system status
445
+ <b>/jobs</b> - List background jobs
446
+ <b>/cron</b> - List scheduled tasks
447
+ <b>/help</b> - Show this help
448
+
449
+ Or just chat naturally - I can:
450
+ • Execute commands
451
+ • Read/write files
452
+ • Search the web
453
+ • Schedule reminders
454
+ • Remember things
455
+ HELP
456
+ end
457
+
458
+ def get_claude_cli_status
459
+ # Check if claude processes are running
460
+ claude_pids = `pgrep -f "claude" 2>/dev/null`.strip.split("\n")
461
+ return nil if claude_pids.empty?
462
+
463
+ lines = []
464
+
465
+ claude_pids.each do |pid|
466
+ # Get process info
467
+ cmd = `ps -p #{pid} -o args= 2>/dev/null`.strip
468
+ next if cmd.empty? || cmd.include?("pgrep")
469
+
470
+ # Get runtime
471
+ etime = `ps -p #{pid} -o etime= 2>/dev/null`.strip
472
+
473
+ # Try to get what it's doing (check /proc on Linux)
474
+ status = "running"
475
+ if File.exist?("/proc/#{pid}/fd")
476
+ # Check if it's doing I/O
477
+ fd_count = begin
478
+ Dir.glob("/proc/#{pid}/fd/*").length
479
+ rescue StandardError
480
+ 0
481
+ end
482
+ status = "active (#{fd_count} fds)" if fd_count > 10
483
+ end
484
+
485
+ # Truncate command for display
486
+ display_cmd = cmd.length > 50 ? "#{cmd[0..47]}..." : cmd
487
+ lines << "PID #{pid}: #{status}"
488
+ lines << " #{display_cmd}" if lines.length < 6
489
+ lines << " Time: #{etime}" if etime.length.positive?
490
+ end
491
+
492
+ lines.empty? ? nil : lines.first(8)
493
+ end
494
+
495
+ def get_jobs_summary
496
+ job_manager = @status_context[:job_manager]
497
+
498
+ # Create job manager lazily from memory_dir if not provided
499
+ if job_manager.nil? && @status_context[:memory_dir]
500
+ begin
501
+ job_manager = Tools::BackgroundJobManager.new(workspace: @status_context[:memory_dir])
502
+ rescue StandardError
503
+ # Ignore if we can't create it
504
+ end
505
+ end
506
+
507
+ return { summary: "N/A", jobs: [], all: [] } unless job_manager&.available?
508
+
509
+ jobs = job_manager.list
510
+ running = jobs.count { |j| j[:running] }
511
+ completed = jobs.length - running
512
+
513
+ {
514
+ summary: "#{running} running, #{completed} completed",
515
+ jobs: jobs.map { |j| "#{j[:running] ? "🟢" : "⚪"} #{j[:name][0..30]}" },
516
+ all: jobs
517
+ }
518
+ end
519
+
520
+ def get_cron_summary
521
+ cron_service = @status_context[:cron_service]
522
+ return { summary: "N/A", jobs: [], all: [] } unless cron_service
523
+
524
+ jobs = cron_service.list_jobs(include_disabled: true)
525
+ enabled = jobs.count(&:enabled)
526
+
527
+ {
528
+ summary: "#{enabled} active, #{jobs.length - enabled} disabled",
529
+ jobs: jobs.select(&:enabled).map { |j| j.name.to_s },
530
+ all: jobs
531
+ }
532
+ end
533
+
534
+ def format_uptime(seconds)
535
+ seconds = seconds.to_i
536
+ if seconds < 60
537
+ "#{seconds}s"
538
+ elsif seconds < 3600
539
+ "#{seconds / 60}m #{seconds % 60}s"
540
+ elsif seconds < 86_400
541
+ "#{seconds / 3600}h #{(seconds % 3600) / 60}m"
542
+ else
543
+ "#{seconds / 86_400}d #{(seconds % 86_400) / 3600}h"
544
+ end
545
+ end
546
+
547
+ def markdown_to_telegram_html(text)
548
+ return "" if text.nil? || text.empty?
549
+
550
+ result = text.dup
551
+
552
+ # Extract and protect code blocks
553
+ code_blocks = []
554
+ result.gsub!(/```\w*\n?([\s\S]*?)```/) do
555
+ code_blocks << Regexp.last_match(1)
556
+ "\x00CB#{code_blocks.length - 1}\x00"
557
+ end
558
+
559
+ # Extract and protect inline code
560
+ inline_codes = []
561
+ result.gsub!(/`([^`]+)`/) do
562
+ inline_codes << Regexp.last_match(1)
563
+ "\x00IC#{inline_codes.length - 1}\x00"
564
+ end
565
+
566
+ # Remove headers (# ## ### etc)
567
+ result.gsub!(/^\#{1,6}\s+(.+)$/, '\1')
568
+
569
+ # Remove blockquotes
570
+ result.gsub!(/^>\s*(.*)$/, '\1')
571
+
572
+ # Escape HTML
573
+ result = result.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
574
+
575
+ # Links [text](url)
576
+ result.gsub!(/\[([^\]]+)\]\(([^)]+)\)/, '<a href="\2">\1</a>')
577
+
578
+ # Bold
579
+ result.gsub!(/\*\*(.+?)\*\*/, '<b>\1</b>')
580
+ result.gsub!(/__(.+?)__/, '<b>\1</b>')
581
+
582
+ # Italic (avoid matching inside words)
583
+ result.gsub!(/(?<![a-zA-Z0-9])_([^_]+)_(?![a-zA-Z0-9])/, '<i>\1</i>')
584
+
585
+ # Strikethrough
586
+ result.gsub!(/~~(.+?)~~/, '<s>\1</s>')
587
+
588
+ # Bullet lists
589
+ result.gsub!(/^[-*]\s+/, "- ")
590
+
591
+ # Restore inline code
592
+ inline_codes.each_with_index do |code, i|
593
+ escaped = code.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
594
+ result.gsub!("\x00IC#{i}\x00", "<code>#{escaped}</code>")
595
+ end
596
+
597
+ # Restore code blocks
598
+ code_blocks.each_with_index do |code, i|
599
+ escaped = code.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
600
+ result.gsub!("\x00CB#{i}\x00", "<pre><code>#{escaped}</code></pre>")
601
+ end
602
+
603
+ result
604
+ end
605
+ end
606
+ end
607
+ end