pocketrb 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +98 -17
- data/lib/pocketrb/agent/compaction.rb +235 -22
- data/lib/pocketrb/agent/context.rb +26 -3
- data/lib/pocketrb/agent/loop.rb +53 -5
- data/lib/pocketrb/agent/spawn_tool.rb +14 -0
- data/lib/pocketrb/agent/subagent_manager.rb +24 -2
- data/lib/pocketrb/bus/events.rb +42 -1
- data/lib/pocketrb/bus/message_bus.rb +37 -0
- data/lib/pocketrb/channels/base.rb +13 -0
- data/lib/pocketrb/channels/cli.rb +16 -1
- data/lib/pocketrb/channels/telegram.rb +70 -4
- data/lib/pocketrb/channels/whatsapp.rb +49 -7
- data/lib/pocketrb/cli/chat.rb +7 -0
- data/lib/pocketrb/cli/config.rb +4 -0
- data/lib/pocketrb/cli/cron.rb +6 -0
- data/lib/pocketrb/cli.rb +0 -1
- data/lib/pocketrb/config.rb +55 -6
- data/lib/pocketrb/cron/job.rb +41 -0
- data/lib/pocketrb/cron/service.rb +44 -15
- data/lib/pocketrb/heartbeat/service.rb +21 -0
- data/lib/pocketrb/mcp/client.rb +41 -3
- data/lib/pocketrb/mcp/memory_tool.rb +33 -0
- data/lib/pocketrb/media/processor.rb +32 -1
- data/lib/pocketrb/memory.rb +12 -3
- data/lib/pocketrb/planning/manager.rb +29 -1
- data/lib/pocketrb/planning/plan.rb +52 -0
- data/lib/pocketrb/planning/tool.rb +45 -0
- data/lib/pocketrb/providers/anthropic.rb +113 -9
- data/lib/pocketrb/providers/base.rb +39 -4
- data/lib/pocketrb/providers/claude_cli.rb +79 -7
- data/lib/pocketrb/providers/claude_max_proxy.rb +72 -6
- data/lib/pocketrb/providers/openai_compatible.rb +79 -0
- data/lib/pocketrb/providers/openrouter.rb +72 -12
- data/lib/pocketrb/providers/registry.rb +24 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +168 -54
- data/lib/pocketrb/providers/types.rb +51 -1
- data/lib/pocketrb/session/manager.rb +34 -2
- data/lib/pocketrb/session/session.rb +53 -11
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +9 -9
- data/lib/pocketrb/skills/create_tool.rb +28 -0
- data/lib/pocketrb/skills/loader.rb +22 -1
- data/lib/pocketrb/skills/modify_tool.rb +22 -0
- data/lib/pocketrb/skills/skill.rb +19 -3
- data/lib/pocketrb/tools/background_job_manager.rb +29 -0
- data/lib/pocketrb/tools/base.rb +16 -1
- data/lib/pocketrb/tools/browser.rb +40 -0
- data/lib/pocketrb/tools/browser_advanced.rb +94 -4
- data/lib/pocketrb/tools/browser_session.rb +18 -0
- data/lib/pocketrb/tools/cron.rb +46 -0
- data/lib/pocketrb/tools/edit_file.rb +16 -0
- data/lib/pocketrb/tools/exec.rb +46 -0
- data/lib/pocketrb/tools/jobs.rb +26 -2
- data/lib/pocketrb/tools/list_dir.rb +19 -0
- data/lib/pocketrb/tools/memory.rb +34 -0
- data/lib/pocketrb/tools/message.rb +15 -0
- data/lib/pocketrb/tools/para_memory.rb +47 -0
- data/lib/pocketrb/tools/read_file.rb +11 -0
- data/lib/pocketrb/tools/registry.rb +12 -4
- data/lib/pocketrb/tools/send_file.rb +29 -0
- data/lib/pocketrb/tools/think.rb +9 -0
- data/lib/pocketrb/tools/web_fetch.rb +58 -3
- data/lib/pocketrb/tools/web_search.rb +25 -0
- data/lib/pocketrb/tools/write_file.rb +10 -0
- data/lib/pocketrb/version.rb +2 -1
- data/lib/pocketrb.rb +4 -1
- data/pocketrb.gemspec +6 -8
- metadata +24 -44
|
@@ -4,9 +4,9 @@ require "telegram/bot"
|
|
|
4
4
|
|
|
5
5
|
module Pocketrb
|
|
6
6
|
module Channels
|
|
7
|
-
# Telegram channel using long polling
|
|
8
|
-
# Simple and reliable - no webhook/public IP needed
|
|
7
|
+
# Telegram channel using long polling. Simple and reliable - no webhook/public IP needed
|
|
9
8
|
class Telegram < Base
|
|
9
|
+
# Markdown to HTML conversion patterns for Telegram formatting
|
|
10
10
|
MARKDOWN_TO_HTML = {
|
|
11
11
|
# Bold **text** or __text__
|
|
12
12
|
/\*\*(.+?)\*\*/ => '<b>\1</b>',
|
|
@@ -19,6 +19,7 @@ module Pocketrb
|
|
|
19
19
|
/`([^`]+)`/ => '<code>\1</code>'
|
|
20
20
|
}.freeze
|
|
21
21
|
|
|
22
|
+
# Telegram file download URL template
|
|
22
23
|
TELEGRAM_FILE_URL = "https://api.telegram.org/file/bot%<token>s/%<path>s"
|
|
23
24
|
|
|
24
25
|
# Special commands that bypass the agent
|
|
@@ -26,6 +27,11 @@ module Pocketrb
|
|
|
26
27
|
|
|
27
28
|
attr_accessor :status_context
|
|
28
29
|
|
|
30
|
+
# Initialize Telegram channel
|
|
31
|
+
# @param bus [Bus::MessageBus] Message bus for publishing and consuming messages
|
|
32
|
+
# @param token [String] Telegram bot API token
|
|
33
|
+
# @param allowed_users [Array<String, Integer>, nil] Allowed usernames or user IDs (nil = allow all)
|
|
34
|
+
# @param download_media [Boolean] Whether to download and process media attachments (defaults to true)
|
|
29
35
|
def initialize(bus:, token:, allowed_users: nil, download_media: true)
|
|
30
36
|
super(bus: bus, name: :telegram)
|
|
31
37
|
@token = token
|
|
@@ -40,6 +46,7 @@ module Pocketrb
|
|
|
40
46
|
|
|
41
47
|
protected
|
|
42
48
|
|
|
49
|
+
# Start the Telegram bot listener thread using long polling
|
|
43
50
|
def run_inbound_loop
|
|
44
51
|
Pocketrb.logger.info("Starting Telegram bot (polling mode)...")
|
|
45
52
|
|
|
@@ -73,6 +80,8 @@ module Pocketrb
|
|
|
73
80
|
raise
|
|
74
81
|
end
|
|
75
82
|
|
|
83
|
+
# Send an outbound message via the Telegram API, with HTML formatting
|
|
84
|
+
# @param message [Bus::OutboundMessage] outbound message to deliver
|
|
76
85
|
def send_message(message)
|
|
77
86
|
return unless @bot
|
|
78
87
|
|
|
@@ -107,6 +116,9 @@ module Pocketrb
|
|
|
107
116
|
end
|
|
108
117
|
end
|
|
109
118
|
|
|
119
|
+
# Dispatch a media item to the appropriate Telegram send method
|
|
120
|
+
# @param chat_id [Integer] Telegram chat identifier
|
|
121
|
+
# @param media [Media::Item] media attachment to send
|
|
110
122
|
def send_media(chat_id, media)
|
|
111
123
|
case media.type
|
|
112
124
|
when :image
|
|
@@ -122,21 +134,33 @@ module Pocketrb
|
|
|
122
134
|
Pocketrb.logger.error("Error sending media: #{e.message}")
|
|
123
135
|
end
|
|
124
136
|
|
|
137
|
+
# Upload and send a photo via the Telegram API
|
|
138
|
+
# @param chat_id [Integer] Telegram chat identifier
|
|
139
|
+
# @param media [Media::Item] image media item
|
|
125
140
|
def send_photo(chat_id, media)
|
|
126
141
|
file = Faraday::Multipart::FilePart.new(media.path, media.mime_type, media.filename)
|
|
127
142
|
@bot.api.send_photo(chat_id: chat_id, photo: file)
|
|
128
143
|
end
|
|
129
144
|
|
|
145
|
+
# Upload and send an audio file via the Telegram API
|
|
146
|
+
# @param chat_id [Integer] Telegram chat identifier
|
|
147
|
+
# @param media [Media::Item] audio media item
|
|
130
148
|
def send_audio(chat_id, media)
|
|
131
149
|
file = Faraday::Multipart::FilePart.new(media.path, media.mime_type, media.filename)
|
|
132
150
|
@bot.api.send_audio(chat_id: chat_id, audio: file)
|
|
133
151
|
end
|
|
134
152
|
|
|
153
|
+
# Upload and send a video file via the Telegram API
|
|
154
|
+
# @param chat_id [Integer] Telegram chat identifier
|
|
155
|
+
# @param media [Media::Item] video media item
|
|
135
156
|
def send_video(chat_id, media)
|
|
136
157
|
file = Faraday::Multipart::FilePart.new(media.path, media.mime_type, media.filename)
|
|
137
158
|
@bot.api.send_video(chat_id: chat_id, video: file)
|
|
138
159
|
end
|
|
139
160
|
|
|
161
|
+
# Upload and send a document file via the Telegram API
|
|
162
|
+
# @param chat_id [Integer] Telegram chat identifier
|
|
163
|
+
# @param media [Media::Item] document media item
|
|
140
164
|
def send_document(chat_id, media)
|
|
141
165
|
file = Faraday::Multipart::FilePart.new(media.path, media.mime_type, media.filename)
|
|
142
166
|
@bot.api.send_document(chat_id: chat_id, document: file)
|
|
@@ -144,6 +168,8 @@ module Pocketrb
|
|
|
144
168
|
|
|
145
169
|
private
|
|
146
170
|
|
|
171
|
+
# Process an incoming Telegram message: check allowlist, extract content, publish inbound
|
|
172
|
+
# @param message [Telegram::Bot::Types::Message] raw Telegram message object
|
|
147
173
|
def handle_telegram_message(message)
|
|
148
174
|
return unless message.is_a?(::Telegram::Bot::Types::Message)
|
|
149
175
|
unless message.text || message.caption || message.photo || message.voice || message.document || message.audio || message.video
|
|
@@ -192,6 +218,9 @@ module Pocketrb
|
|
|
192
218
|
@bus.publish_inbound(inbound)
|
|
193
219
|
end
|
|
194
220
|
|
|
221
|
+
# Build a sender identifier string from a Telegram user
|
|
222
|
+
# @param user [Telegram::Bot::Types::User] Telegram user object
|
|
223
|
+
# @return [String] sender identifier combining user ID and username
|
|
195
224
|
def build_sender_id(user)
|
|
196
225
|
if user.username
|
|
197
226
|
"#{user.id}|#{user.username}"
|
|
@@ -200,6 +229,9 @@ module Pocketrb
|
|
|
200
229
|
end
|
|
201
230
|
end
|
|
202
231
|
|
|
232
|
+
# Extract text content and media descriptions from a Telegram message
|
|
233
|
+
# @param message [Telegram::Bot::Types::Message] raw Telegram message object
|
|
234
|
+
# @return [String] combined text content
|
|
203
235
|
def build_content(message)
|
|
204
236
|
parts = []
|
|
205
237
|
parts << message.text if message.text
|
|
@@ -219,6 +251,9 @@ module Pocketrb
|
|
|
219
251
|
parts.empty? ? "[empty message]" : parts.join("\n")
|
|
220
252
|
end
|
|
221
253
|
|
|
254
|
+
# Download all media attachments from a Telegram message
|
|
255
|
+
# @param message [Telegram::Bot::Types::Message] raw Telegram message object
|
|
256
|
+
# @return [Array<Media::Item>] downloaded media items
|
|
222
257
|
def download_media(message)
|
|
223
258
|
return [] unless @download_media
|
|
224
259
|
|
|
@@ -277,6 +312,12 @@ module Pocketrb
|
|
|
277
312
|
media
|
|
278
313
|
end
|
|
279
314
|
|
|
315
|
+
# Download a file from Telegram servers by file ID
|
|
316
|
+
# @param file_id [String] Telegram file identifier
|
|
317
|
+
# @param _type [Symbol] media type (unused, kept for interface consistency)
|
|
318
|
+
# @param mime_type [String] MIME type of the file
|
|
319
|
+
# @param filename [String, nil] optional filename override
|
|
320
|
+
# @return [Media::Item, nil] downloaded media item or nil on failure
|
|
280
321
|
def download_telegram_file(file_id, _type, mime_type, filename = nil)
|
|
281
322
|
# Get file path from Telegram
|
|
282
323
|
result = @bot.api.get_file(file_id: file_id)
|
|
@@ -300,6 +341,9 @@ module Pocketrb
|
|
|
300
341
|
nil
|
|
301
342
|
end
|
|
302
343
|
|
|
344
|
+
# Check if a Telegram user is in the allowlist
|
|
345
|
+
# @param user [Telegram::Bot::Types::User] Telegram user to check
|
|
346
|
+
# @return [Boolean] true if user is allowed or no allowlist is configured
|
|
303
347
|
def allowed_user?(user)
|
|
304
348
|
return true if @allowed_users.nil? || @allowed_users.empty?
|
|
305
349
|
|
|
@@ -310,6 +354,9 @@ module Pocketrb
|
|
|
310
354
|
end
|
|
311
355
|
|
|
312
356
|
# Handle special commands that bypass the agent
|
|
357
|
+
# @param text [String] message text to check for commands
|
|
358
|
+
# @param chat_id [Integer] Telegram chat identifier for the reply
|
|
359
|
+
# @return [Boolean] true if a special command was handled
|
|
313
360
|
def handle_special_command(text, chat_id)
|
|
314
361
|
cmd = text.strip.split.first&.downcase
|
|
315
362
|
return false unless SPECIAL_COMMANDS.include?(cmd)
|
|
@@ -334,6 +381,8 @@ module Pocketrb
|
|
|
334
381
|
true
|
|
335
382
|
end
|
|
336
383
|
|
|
384
|
+
# Build an HTML-formatted status overview for the /status command
|
|
385
|
+
# @return [String] HTML-formatted status text
|
|
337
386
|
def build_status_response
|
|
338
387
|
lines = []
|
|
339
388
|
lines << "━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
@@ -388,14 +437,15 @@ module Pocketrb
|
|
|
388
437
|
lines.join("\n")
|
|
389
438
|
end
|
|
390
439
|
|
|
440
|
+
# Build an HTML-formatted list of background jobs for the /jobs command
|
|
441
|
+
# @return [String] HTML-formatted jobs text
|
|
391
442
|
def build_jobs_response
|
|
392
443
|
jobs_info = get_jobs_summary
|
|
393
444
|
return "No background jobs." if jobs_info[:jobs].empty?
|
|
394
445
|
|
|
395
446
|
lines = ["<b>📋 Background Jobs</b>", ""]
|
|
396
447
|
|
|
397
|
-
running = jobs_info[:all].
|
|
398
|
-
completed = jobs_info[:all].reject { |j| j[:running] }
|
|
448
|
+
running, completed = jobs_info[:all].partition { |j| j[:running] }
|
|
399
449
|
|
|
400
450
|
if running.any?
|
|
401
451
|
lines << "<b>Running:</b>"
|
|
@@ -415,6 +465,8 @@ module Pocketrb
|
|
|
415
465
|
lines.join("\n")
|
|
416
466
|
end
|
|
417
467
|
|
|
468
|
+
# Build an HTML-formatted list of cron jobs for the /cron command
|
|
469
|
+
# @return [String] HTML-formatted cron jobs text
|
|
418
470
|
def build_cron_response
|
|
419
471
|
cron_info = get_cron_summary
|
|
420
472
|
return "No scheduled jobs." if cron_info[:all].empty?
|
|
@@ -437,6 +489,8 @@ module Pocketrb
|
|
|
437
489
|
lines.join("\n")
|
|
438
490
|
end
|
|
439
491
|
|
|
492
|
+
# Build an HTML-formatted help text for the /help command
|
|
493
|
+
# @return [String] HTML-formatted help text
|
|
440
494
|
def build_help_response
|
|
441
495
|
<<~HELP
|
|
442
496
|
<b>🤖 Pocketrb Commands</b>
|
|
@@ -455,6 +509,8 @@ module Pocketrb
|
|
|
455
509
|
HELP
|
|
456
510
|
end
|
|
457
511
|
|
|
512
|
+
# Detect running Claude CLI processes and return their status
|
|
513
|
+
# @return [Array<String>, nil] status lines or nil if no processes found
|
|
458
514
|
def get_claude_cli_status
|
|
459
515
|
# Check if claude processes are running
|
|
460
516
|
claude_pids = `pgrep -f "claude" 2>/dev/null`.strip.split("\n")
|
|
@@ -492,6 +548,8 @@ module Pocketrb
|
|
|
492
548
|
lines.empty? ? nil : lines.first(8)
|
|
493
549
|
end
|
|
494
550
|
|
|
551
|
+
# Collect background job counts and descriptions
|
|
552
|
+
# @return [Hash] summary with :summary, :jobs, and :all keys
|
|
495
553
|
def get_jobs_summary
|
|
496
554
|
job_manager = @status_context[:job_manager]
|
|
497
555
|
|
|
@@ -517,6 +575,8 @@ module Pocketrb
|
|
|
517
575
|
}
|
|
518
576
|
end
|
|
519
577
|
|
|
578
|
+
# Collect cron job counts and descriptions
|
|
579
|
+
# @return [Hash] summary with :summary, :jobs, and :all keys
|
|
520
580
|
def get_cron_summary
|
|
521
581
|
cron_service = @status_context[:cron_service]
|
|
522
582
|
return { summary: "N/A", jobs: [], all: [] } unless cron_service
|
|
@@ -531,6 +591,9 @@ module Pocketrb
|
|
|
531
591
|
}
|
|
532
592
|
end
|
|
533
593
|
|
|
594
|
+
# Format a duration in seconds as a human-readable uptime string
|
|
595
|
+
# @param seconds [Numeric] elapsed seconds
|
|
596
|
+
# @return [String] formatted uptime (e.g. "2h 30m")
|
|
534
597
|
def format_uptime(seconds)
|
|
535
598
|
seconds = seconds.to_i
|
|
536
599
|
if seconds < 60
|
|
@@ -544,6 +607,9 @@ module Pocketrb
|
|
|
544
607
|
end
|
|
545
608
|
end
|
|
546
609
|
|
|
610
|
+
# Convert Markdown text to Telegram-compatible HTML
|
|
611
|
+
# @param text [String, nil] Markdown-formatted text
|
|
612
|
+
# @return [String] HTML-formatted text safe for Telegram
|
|
547
613
|
def markdown_to_telegram_html(text)
|
|
548
614
|
return "" if text.nil? || text.empty?
|
|
549
615
|
|
|
@@ -5,8 +5,7 @@ require "socket"
|
|
|
5
5
|
|
|
6
6
|
module Pocketrb
|
|
7
7
|
module Channels
|
|
8
|
-
# WhatsApp channel via Node.js bridge WebSocket
|
|
9
|
-
# Connects to a whatsapp-web.js bridge running on localhost
|
|
8
|
+
# WhatsApp channel via Node.js bridge WebSocket. Connects to a whatsapp-web.js bridge running on localhost
|
|
10
9
|
#
|
|
11
10
|
# Bridge Protocol:
|
|
12
11
|
# - Receive: {type: "message", sender: "...", content: "...", timestamp: ..., isGroup: bool}
|
|
@@ -14,9 +13,16 @@ module Pocketrb
|
|
|
14
13
|
# - Status: {type: "status", status: "connected"}
|
|
15
14
|
# - QR: {type: "qr", qr: "data:image/png;base64,..."}
|
|
16
15
|
class WhatsApp < Base
|
|
16
|
+
# Delay in seconds before reconnecting after disconnect
|
|
17
17
|
RECONNECT_DELAY = 5
|
|
18
|
+
# Default WebSocket bridge URL
|
|
18
19
|
DEFAULT_BRIDGE_URL = "ws://localhost:3001"
|
|
19
20
|
|
|
21
|
+
# Initialize WhatsApp channel
|
|
22
|
+
# @param bus [Bus::MessageBus] Message bus for publishing and consuming messages
|
|
23
|
+
# @param bridge_url [String] WebSocket bridge URL (defaults to ws://localhost:3001)
|
|
24
|
+
# @param allowed_users [Array<String>, nil] Allowed phone numbers (nil = allow all)
|
|
25
|
+
# @param download_media [Boolean] Whether to download and process media attachments (defaults to true)
|
|
20
26
|
def initialize(bus:, bridge_url: DEFAULT_BRIDGE_URL, allowed_users: nil, download_media: true)
|
|
21
27
|
super(bus: bus, name: :whatsapp)
|
|
22
28
|
@bridge_url = bridge_url
|
|
@@ -28,6 +34,7 @@ module Pocketrb
|
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
# Check if we can connect to the bridge
|
|
37
|
+
# @return [Boolean] true if the WebSocket client library is available
|
|
31
38
|
def available?
|
|
32
39
|
require "websocket-client-simple"
|
|
33
40
|
true
|
|
@@ -35,11 +42,22 @@ module Pocketrb
|
|
|
35
42
|
false
|
|
36
43
|
end
|
|
37
44
|
|
|
45
|
+
# Start the channel
|
|
46
|
+
# @raise [MissingDependencyError] when the websocket-client-simple gem is not installed
|
|
47
|
+
def run
|
|
48
|
+
unless available?
|
|
49
|
+
raise MissingDependencyError,
|
|
50
|
+
"The WhatsApp channel needs the websocket-client-simple gem. " \
|
|
51
|
+
"Add `gem \"websocket-client-simple\"` to your Gemfile or run: gem install websocket-client-simple"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
38
57
|
protected
|
|
39
58
|
|
|
59
|
+
# Connect to the WhatsApp bridge and listen for inbound messages
|
|
40
60
|
def run_inbound_loop
|
|
41
|
-
require "websocket-client-simple"
|
|
42
|
-
|
|
43
61
|
Pocketrb.logger.info("Starting WhatsApp channel (bridge: #{@bridge_url})")
|
|
44
62
|
|
|
45
63
|
loop do
|
|
@@ -56,11 +74,10 @@ module Pocketrb
|
|
|
56
74
|
sleep RECONNECT_DELAY
|
|
57
75
|
end
|
|
58
76
|
end
|
|
59
|
-
rescue LoadError
|
|
60
|
-
Pocketrb.logger.error("websocket-client-simple gem not installed. Run: gem install websocket-client-simple")
|
|
61
|
-
raise
|
|
62
77
|
end
|
|
63
78
|
|
|
79
|
+
# Send an outbound message via the WhatsApp WebSocket bridge
|
|
80
|
+
# @param message [Bus::OutboundMessage] outbound message to deliver
|
|
64
81
|
def send_message(message)
|
|
65
82
|
return unless @ws && @connected
|
|
66
83
|
|
|
@@ -79,6 +96,7 @@ module Pocketrb
|
|
|
79
96
|
|
|
80
97
|
private
|
|
81
98
|
|
|
99
|
+
# Establish WebSocket connection and register event handlers
|
|
82
100
|
def connect_and_listen
|
|
83
101
|
channel = self
|
|
84
102
|
|
|
@@ -106,6 +124,8 @@ module Pocketrb
|
|
|
106
124
|
sleep 0.5 while @ws.open? && @running
|
|
107
125
|
end
|
|
108
126
|
|
|
127
|
+
# Route an incoming bridge WebSocket message by type
|
|
128
|
+
# @param data [String] raw JSON string from the bridge
|
|
109
129
|
def handle_bridge_message(data)
|
|
110
130
|
message = JSON.parse(data)
|
|
111
131
|
|
|
@@ -129,6 +149,8 @@ module Pocketrb
|
|
|
129
149
|
Pocketrb.logger.warn("WhatsApp: invalid JSON: #{e.message}")
|
|
130
150
|
end
|
|
131
151
|
|
|
152
|
+
# Process an incoming WhatsApp message and publish it to the bus
|
|
153
|
+
# @param message [Hash] parsed bridge message payload
|
|
132
154
|
def handle_incoming_message(message)
|
|
133
155
|
sender = extract_phone(message["sender"] || message["from"])
|
|
134
156
|
return unless allowed_user?(sender)
|
|
@@ -171,6 +193,9 @@ module Pocketrb
|
|
|
171
193
|
@bus.publish_inbound(inbound)
|
|
172
194
|
end
|
|
173
195
|
|
|
196
|
+
# Download or decode media attached to a WhatsApp message
|
|
197
|
+
# @param message [Hash] parsed bridge message with media data
|
|
198
|
+
# @return [Media::Item, nil] processed media item or nil on failure
|
|
174
199
|
def process_whatsapp_media(message)
|
|
175
200
|
return nil unless message["mediaData"] || message["mediaUrl"]
|
|
176
201
|
|
|
@@ -191,6 +216,9 @@ module Pocketrb
|
|
|
191
216
|
nil
|
|
192
217
|
end
|
|
193
218
|
|
|
219
|
+
# Map a MIME type to a file extension
|
|
220
|
+
# @param mime_type [String] MIME type string
|
|
221
|
+
# @return [String] file extension without dot
|
|
194
222
|
def extension_for_mime(mime_type)
|
|
195
223
|
case mime_type
|
|
196
224
|
when %r{^image/jpeg} then "jpg"
|
|
@@ -204,6 +232,8 @@ module Pocketrb
|
|
|
204
232
|
end
|
|
205
233
|
end
|
|
206
234
|
|
|
235
|
+
# Log a received QR code event from the bridge
|
|
236
|
+
# @param message [Hash] bridge message containing QR data
|
|
207
237
|
def handle_qr(message)
|
|
208
238
|
return unless message["qr"]
|
|
209
239
|
|
|
@@ -211,6 +241,9 @@ module Pocketrb
|
|
|
211
241
|
# Could save to file or display in terminal if needed
|
|
212
242
|
end
|
|
213
243
|
|
|
244
|
+
# Check if a phone number is in the allowlist
|
|
245
|
+
# @param phone [String] phone number to check
|
|
246
|
+
# @return [Boolean] true if allowed or no allowlist is configured
|
|
214
247
|
def allowed_user?(phone)
|
|
215
248
|
return true if @allowed_users.nil? || @allowed_users.empty?
|
|
216
249
|
|
|
@@ -218,6 +251,9 @@ module Pocketrb
|
|
|
218
251
|
@allowed_users.any? { |allowed| normalize_phone(allowed) == normalized }
|
|
219
252
|
end
|
|
220
253
|
|
|
254
|
+
# Convert a phone number to a WhatsApp JID
|
|
255
|
+
# @param phone [String] phone number or existing JID
|
|
256
|
+
# @return [String] WhatsApp JID (e.g. "1234@s.whatsapp.net")
|
|
221
257
|
def to_jid(phone)
|
|
222
258
|
return phone if phone.include?("@")
|
|
223
259
|
|
|
@@ -226,12 +262,18 @@ module Pocketrb
|
|
|
226
262
|
"#{clean}@s.whatsapp.net"
|
|
227
263
|
end
|
|
228
264
|
|
|
265
|
+
# Extract the phone number portion from a WhatsApp JID
|
|
266
|
+
# @param jid [String, nil] WhatsApp JID string
|
|
267
|
+
# @return [String, nil] phone number or original value
|
|
229
268
|
def extract_phone(jid)
|
|
230
269
|
return jid unless jid
|
|
231
270
|
|
|
232
271
|
jid.split("@").first
|
|
233
272
|
end
|
|
234
273
|
|
|
274
|
+
# Strip non-digit characters from a phone number for comparison
|
|
275
|
+
# @param phone [String, nil] phone number to normalize
|
|
276
|
+
# @return [String, nil] digits-only phone number or nil
|
|
235
277
|
def normalize_phone(phone)
|
|
236
278
|
return nil unless phone
|
|
237
279
|
|
data/lib/pocketrb/cli/chat.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "tty-spinner"
|
|
4
|
+
|
|
3
5
|
module Pocketrb
|
|
4
6
|
class CLI
|
|
5
7
|
# Chat command - interactive chat mode
|
|
@@ -53,7 +55,12 @@ module Pocketrb
|
|
|
53
55
|
content: input
|
|
54
56
|
)
|
|
55
57
|
|
|
58
|
+
spinner = TTY::Spinner.new("[:spinner] Thinking...", format: :dots)
|
|
59
|
+
spinner.auto_spin
|
|
60
|
+
|
|
56
61
|
response = agent_loop.process_message(msg)
|
|
62
|
+
|
|
63
|
+
spinner.stop
|
|
57
64
|
puts "\n#{response.content}" if response
|
|
58
65
|
end
|
|
59
66
|
end
|
data/lib/pocketrb/cli/config.rb
CHANGED
|
@@ -5,6 +5,7 @@ module Pocketrb
|
|
|
5
5
|
# Config command - manages configuration
|
|
6
6
|
class Config < Thor
|
|
7
7
|
desc "show", "Show current configuration"
|
|
8
|
+
# Display current configuration values
|
|
8
9
|
def show
|
|
9
10
|
workspace = options[:workspace] || Dir.pwd
|
|
10
11
|
config = Pocketrb::Config.load(workspace)
|
|
@@ -18,6 +19,8 @@ module Pocketrb
|
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
desc "set KEY VALUE", "Set a configuration value"
|
|
22
|
+
# @param key [String] Configuration key to set
|
|
23
|
+
# @param value [String] Value to assign (will be auto-converted to int, float, or boolean if applicable)
|
|
21
24
|
def set(key, value)
|
|
22
25
|
workspace = options[:workspace] || Dir.pwd
|
|
23
26
|
config = Pocketrb::Config.load(workspace)
|
|
@@ -36,6 +39,7 @@ module Pocketrb
|
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
desc "get KEY", "Get a configuration value"
|
|
42
|
+
# @param key [String] Configuration key to retrieve
|
|
39
43
|
def get(key)
|
|
40
44
|
workspace = options[:workspace] || Dir.pwd
|
|
41
45
|
config = Pocketrb::Config.load(workspace)
|
data/lib/pocketrb/cli/cron.rb
CHANGED
|
@@ -6,6 +6,7 @@ module Pocketrb
|
|
|
6
6
|
class Cron < Thor
|
|
7
7
|
desc "list", "List scheduled jobs"
|
|
8
8
|
option :all, type: :boolean, aliases: "-a", desc: "Include disabled jobs"
|
|
9
|
+
# List all scheduled cron jobs
|
|
9
10
|
def list
|
|
10
11
|
workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
|
|
11
12
|
cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
|
|
@@ -38,6 +39,7 @@ module Pocketrb
|
|
|
38
39
|
option :deliver, type: :boolean, default: false, desc: "Deliver to channel instead of processing"
|
|
39
40
|
option :channel, type: :string, desc: "Target channel for delivery"
|
|
40
41
|
option :to, type: :string, desc: "Target chat ID for delivery"
|
|
42
|
+
# Add a new scheduled cron job
|
|
41
43
|
def add
|
|
42
44
|
workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
|
|
43
45
|
cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
|
|
@@ -84,6 +86,7 @@ module Pocketrb
|
|
|
84
86
|
end
|
|
85
87
|
|
|
86
88
|
desc "remove JOB_ID", "Remove a scheduled job"
|
|
89
|
+
# @param job_id [String] Job identifier to remove
|
|
87
90
|
def remove(job_id)
|
|
88
91
|
workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
|
|
89
92
|
cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
|
|
@@ -101,6 +104,7 @@ module Pocketrb
|
|
|
101
104
|
end
|
|
102
105
|
|
|
103
106
|
desc "enable JOB_ID", "Enable a scheduled job"
|
|
107
|
+
# @param job_id [String] Job identifier to enable
|
|
104
108
|
def enable(job_id)
|
|
105
109
|
workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
|
|
106
110
|
cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
|
|
@@ -118,6 +122,7 @@ module Pocketrb
|
|
|
118
122
|
end
|
|
119
123
|
|
|
120
124
|
desc "disable JOB_ID", "Disable a scheduled job"
|
|
125
|
+
# @param job_id [String] Job identifier to disable
|
|
121
126
|
def disable(job_id)
|
|
122
127
|
workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
|
|
123
128
|
cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
|
|
@@ -135,6 +140,7 @@ module Pocketrb
|
|
|
135
140
|
end
|
|
136
141
|
|
|
137
142
|
desc "trigger JOB_ID", "Trigger a job manually"
|
|
143
|
+
# @param _job_id [String] Job identifier to trigger (unused in current implementation)
|
|
138
144
|
def trigger(_job_id)
|
|
139
145
|
say "Manual job execution requires running gateway", :yellow
|
|
140
146
|
say "Use 'pocketrb gateway' and the job will be executed", :yellow
|