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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +456 -0
- data/exe/pocketrb +6 -0
- data/lib/pocketrb/agent/compaction.rb +187 -0
- data/lib/pocketrb/agent/context.rb +171 -0
- data/lib/pocketrb/agent/loop.rb +276 -0
- data/lib/pocketrb/agent/spawn_tool.rb +72 -0
- data/lib/pocketrb/agent/subagent_manager.rb +196 -0
- data/lib/pocketrb/bus/events.rb +99 -0
- data/lib/pocketrb/bus/message_bus.rb +148 -0
- data/lib/pocketrb/channels/base.rb +69 -0
- data/lib/pocketrb/channels/cli.rb +109 -0
- data/lib/pocketrb/channels/telegram.rb +607 -0
- data/lib/pocketrb/channels/whatsapp.rb +242 -0
- data/lib/pocketrb/cli/base.rb +119 -0
- data/lib/pocketrb/cli/chat.rb +67 -0
- data/lib/pocketrb/cli/config.rb +52 -0
- data/lib/pocketrb/cli/cron.rb +144 -0
- data/lib/pocketrb/cli/gateway.rb +132 -0
- data/lib/pocketrb/cli/init.rb +39 -0
- data/lib/pocketrb/cli/plans.rb +28 -0
- data/lib/pocketrb/cli/skills.rb +34 -0
- data/lib/pocketrb/cli/start.rb +55 -0
- data/lib/pocketrb/cli/telegram.rb +93 -0
- data/lib/pocketrb/cli/version.rb +18 -0
- data/lib/pocketrb/cli/whatsapp.rb +60 -0
- data/lib/pocketrb/cli.rb +124 -0
- data/lib/pocketrb/config.rb +190 -0
- data/lib/pocketrb/cron/job.rb +155 -0
- data/lib/pocketrb/cron/service.rb +395 -0
- data/lib/pocketrb/heartbeat/service.rb +175 -0
- data/lib/pocketrb/mcp/client.rb +172 -0
- data/lib/pocketrb/mcp/memory_tool.rb +133 -0
- data/lib/pocketrb/media/processor.rb +258 -0
- data/lib/pocketrb/memory.rb +283 -0
- data/lib/pocketrb/planning/manager.rb +159 -0
- data/lib/pocketrb/planning/plan.rb +223 -0
- data/lib/pocketrb/planning/tool.rb +176 -0
- data/lib/pocketrb/providers/anthropic.rb +333 -0
- data/lib/pocketrb/providers/base.rb +98 -0
- data/lib/pocketrb/providers/claude_cli.rb +412 -0
- data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
- data/lib/pocketrb/providers/openrouter.rb +205 -0
- data/lib/pocketrb/providers/registry.rb +59 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
- data/lib/pocketrb/providers/types.rb +111 -0
- data/lib/pocketrb/session/manager.rb +192 -0
- data/lib/pocketrb/session/session.rb +204 -0
- data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
- data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
- data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
- data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
- data/lib/pocketrb/skills/create_tool.rb +115 -0
- data/lib/pocketrb/skills/loader.rb +164 -0
- data/lib/pocketrb/skills/modify_tool.rb +123 -0
- data/lib/pocketrb/skills/skill.rb +75 -0
- data/lib/pocketrb/tools/background_job_manager.rb +261 -0
- data/lib/pocketrb/tools/base.rb +118 -0
- data/lib/pocketrb/tools/browser.rb +152 -0
- data/lib/pocketrb/tools/browser_advanced.rb +470 -0
- data/lib/pocketrb/tools/browser_session.rb +167 -0
- data/lib/pocketrb/tools/cron.rb +222 -0
- data/lib/pocketrb/tools/edit_file.rb +101 -0
- data/lib/pocketrb/tools/exec.rb +194 -0
- data/lib/pocketrb/tools/jobs.rb +127 -0
- data/lib/pocketrb/tools/list_dir.rb +102 -0
- data/lib/pocketrb/tools/memory.rb +167 -0
- data/lib/pocketrb/tools/message.rb +70 -0
- data/lib/pocketrb/tools/para_memory.rb +264 -0
- data/lib/pocketrb/tools/read_file.rb +65 -0
- data/lib/pocketrb/tools/registry.rb +160 -0
- data/lib/pocketrb/tools/send_file.rb +158 -0
- data/lib/pocketrb/tools/think.rb +35 -0
- data/lib/pocketrb/tools/web_fetch.rb +150 -0
- data/lib/pocketrb/tools/web_search.rb +102 -0
- data/lib/pocketrb/tools/write_file.rb +55 -0
- data/lib/pocketrb/version.rb +5 -0
- data/lib/pocketrb.rb +75 -0
- data/pocketrb.gemspec +60 -0
- 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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
600
|
+
result.gsub!("\x00CB#{i}\x00", "<pre><code>#{escaped}</code></pre>")
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
result
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
end
|