ask-channel-providers 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 46aa67b47282d92147eff1ae08bb656a74194d54d27d58659fa4a93f30c7154c
4
+ data.tar.gz: 3ab87db22f0790870a7e4531fc67d0040fb151fbdecd4536463233373969e28f
5
+ SHA512:
6
+ metadata.gz: b86910b42f6c353b1da62967303d449bcf34586013f882b07f59b58c154446c5e7dd21d24962a92efd211655f5eed66f7d029637ea203ca79c75b8ef4fc20247
7
+ data.tar.gz: ad810d49236687268290514e5ad51772a112f9da9928ec16c1647f997fc43d9f99fc58c8d0fab529f5e9ae711da3a0057b924a1ca94cd7b998515aa756d30cc6
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module ChannelProviders
5
+ # Abstract base class for channel adapters.
6
+ #
7
+ # A channel adapter wraps a messaging platform (e.g., Telegram, Discord, Slack)
8
+ # and provides a uniform interface for the coder engine to communicate through it.
9
+ #
10
+ # Subclasses must implement all methods that raise NotImplementedError.
11
+ class Adapter
12
+ # Start the channel and begin receiving messages.
13
+ #
14
+ # The channel should call the provided block whenever a user message arrives.
15
+ # The block receives { chat_id:, user_id:, text:, session_key: }.
16
+ #
17
+ # @param config [Hash] channel-specific configuration
18
+ # @param on_message [Proc] callback for incoming messages
19
+ def start(config: {}, &on_message)
20
+ raise NotImplementedError, "#{self.class} must implement #start"
21
+ end
22
+
23
+ # Stop the channel gracefully.
24
+ def stop
25
+ raise NotImplementedError, "#{self.class} must implement #stop"
26
+ end
27
+
28
+ # Send a text message to a chat.
29
+ #
30
+ # @param chat_id [Integer, String] the chat/thread identifier
31
+ # @param text [String] the message text
32
+ # @return [void]
33
+ def send_message(chat_id, text)
34
+ raise NotImplementedError, "#{self.class} must implement #send_message"
35
+ end
36
+
37
+ # Edit a previously sent message (for streaming updates).
38
+ #
39
+ # @param chat_id [Integer, String] the chat/thread identifier
40
+ # @param message_id [Integer] the message ID to edit
41
+ # @param text [String] the new text
42
+ # @return [void]
43
+ def edit_message(chat_id, message_id, text)
44
+ raise NotImplementedError, "#{self.class} must implement #edit_message"
45
+ end
46
+
47
+ # Send an approval request with accept/decline buttons.
48
+ #
49
+ # @param chat_id [Integer, String] the chat/thread identifier
50
+ # @param tool_name [String] the name of the tool requesting permission
51
+ # @param risk_level [String] the risk level string
52
+ # @param details [String] human-readable details about the request
53
+ # @return [void]
54
+ def request_approval(chat_id, tool_name:, risk_level:, details:)
55
+ raise NotImplementedError, "#{self.class} must implement #request_approval"
56
+ end
57
+
58
+ # Send a rich card (cross-platform UI).
59
+ #
60
+ # Each adapter renders the Card to its native format.
61
+ # Default implementation falls back to markdown text.
62
+ #
63
+ # @param chat_id [Integer, String] the chat identifier
64
+ # @param card [Card] the card to render
65
+ def send_card(chat_id, card)
66
+ text = render_card_to_text(card)
67
+ send_message(chat_id, text) if text
68
+ end
69
+
70
+ # Render a card to plain text fallback.
71
+ # Override in platform adapters for rich rendering.
72
+ def render_card_to_text(card)
73
+ lines = []
74
+ card.sections.each do |section|
75
+ lines << "**#{section.title}**" unless section.title.empty?
76
+ section.components.each do |comp|
77
+ case comp
78
+ when Card::TextBlock
79
+ lines << comp.content
80
+ when Card::Table
81
+ lines << "| #{comp.header.join(' | ')} |"
82
+ lines << "| #{comp.header.map { '-' * _1.length }.join(' | ')} |"
83
+ comp.rows.each { |row| lines << "| #{row.join(' | ')} |" }
84
+ when Card::Divider
85
+ lines << "---"
86
+ end
87
+ end
88
+ lines << ""
89
+ end
90
+ lines.join("\n").strip
91
+ end
92
+
93
+ # Register a callback handler for interactive button presses.
94
+ # Called with { chat_id:, user_id:, data:, callback_query_id: }
95
+ def set_callback_handler(handler)
96
+ @callback_handler = handler
97
+ end
98
+
99
+ # Whether the channel connection is active.
100
+ #
101
+ # @return [Boolean]
102
+ def running?
103
+ false
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module ChannelProviders
5
+ # Cross-platform card system for rich message rendering.
6
+ #
7
+ # A Card is a structured UI element that each channel adapter renders
8
+ # into its native format:
9
+ # Telegram → Markdown text + inline keyboards
10
+ # Discord → Embeds + message components
11
+ # Slack → Block Kit
12
+ #
13
+ # @example
14
+ # card = Ask::ChannelProviders::Card.new do |c|
15
+ # c.section "Deploy Status"
16
+ # c.text "Build **#42** passed"
17
+ # c.button "View Logs", callback: "/logs 42"
18
+ # c.table header: ["File", "Status"], rows: [["app.rb", "✅"]]
19
+ # end
20
+ # @channel.send_card(chat_id, card)
21
+ class Card
22
+ attr_reader :sections
23
+
24
+ def initialize
25
+ @sections = []
26
+ yield self if block_given?
27
+ end
28
+
29
+ # Add a section title.
30
+ def section(text)
31
+ @sections << Section.new(text)
32
+ end
33
+
34
+ # Add a text block to the last section.
35
+ def text(content, style: nil)
36
+ last_section << TextBlock.new(content: content, style: style)
37
+ end
38
+
39
+ # Add a button to the last section.
40
+ def button(label, callback: nil, url: nil)
41
+ last_section << Button.new(label: label, callback: callback, url: url)
42
+ end
43
+
44
+ # Add a table to the last section.
45
+ def table(header:, rows:)
46
+ last_section << Table.new(header: header, rows: rows)
47
+ end
48
+
49
+ # Add a divider line.
50
+ def divider
51
+ last_section << Divider.new
52
+ end
53
+
54
+ private
55
+
56
+ def last_section
57
+ @sections.last || (@sections << Section.new(""))[-1]
58
+ end
59
+
60
+ # ── Components ──────────────────────────────────────────────────────
61
+
62
+ Section = Struct.new(:title) do
63
+ attr_reader :components
64
+
65
+ def initialize(title)
66
+ super(title)
67
+ @components = []
68
+ end
69
+
70
+ def <<(component)
71
+ @components << component
72
+ end
73
+ end
74
+
75
+ TextBlock = Struct.new(:content, :style, keyword_init: true)
76
+
77
+ Button = Struct.new(:label, :callback, :url, keyword_init: true)
78
+
79
+ Table = Struct.new(:header, :rows, keyword_init: true)
80
+
81
+ Divider = Struct.new
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module ChannelProviders
5
+ module Telegram
6
+ # ChannelAdapter implementation for Telegram.
7
+ #
8
+ # Wraps Ask::ChannelProviders::Telegram::Bot into the Adapter interface,
9
+ # handling message dispatch, streaming edits, and approval requests.
10
+ class Adapter < Ask::ChannelProviders::Adapter
11
+ # @param token [String] Telegram bot token
12
+ # @param allowed_users [Array<Integer>] allowed user IDs (empty = all)
13
+ # @param allowed_chats [Array<Integer>] allowed group chat IDs (empty = all)
14
+ def initialize(token:, allowed_users: [], allowed_chats: [])
15
+ @token = token
16
+ @allowed_users = allowed_users
17
+ @allowed_chats = allowed_chats
18
+ @bot = nil
19
+ @bot_user_id = nil
20
+ @message_handler = nil
21
+ @callback_handler = nil
22
+ @pending_permissions = {} # callback_data -> Queue for approval responses
23
+ end
24
+
25
+ def start(config: {}, &on_message)
26
+ @message_handler = on_message
27
+ @bot = Bot.new(token: @token)
28
+ @bot.start do |msg|
29
+ handle_incoming(msg)
30
+ end
31
+ @bot.on_callback = method(:handle_callback)
32
+ @bot_user_id = @bot.bot_user_id
33
+ end
34
+
35
+ def stop
36
+ @bot&.stop
37
+ @bot = nil
38
+ end
39
+
40
+ def running?
41
+ @bot&.running? || false
42
+ end
43
+
44
+ # Send a text message, returns the message ID.
45
+ def send_message(chat_id, text)
46
+ log("[response] #{text[0..80]}...")
47
+ response = try_send_markdown(chat_id, text)
48
+ response ||= @bot&.send_message(chat_id: chat_id, text: text)
49
+ response&.dig("result", "message_id")
50
+ rescue => e
51
+ nil
52
+ end
53
+
54
+ # Edit a previously sent message (streaming updates).
55
+ def edit_message(chat_id, message_id, text)
56
+ log("[response] edit #{message_id}: #{text[0..60]}...")
57
+ return unless @bot && message_id
58
+ try_edit_markdown(chat_id, message_id, text) ||
59
+ @bot.edit_message(chat_id: chat_id, message_id: message_id, text: text)
60
+ rescue => e
61
+ nil
62
+ end
63
+
64
+ # Handle inline keyboard button clicks.
65
+ def handle_callback(callback)
66
+ cq_id = callback[:callback_query_id]
67
+ data = callback[:data]
68
+ chat_id = callback[:chat_id]
69
+ user_id = callback[:user_id]
70
+
71
+ # Answer the callback (removes loading spinner on the button)
72
+ @bot&.answer_callback_query(callback_query_id: cq_id)
73
+
74
+ # Check if this is a permission approval callback
75
+ if data && data.include?(":allow")
76
+ callback_id = data.sub(":allow", "")
77
+ if (queue = @pending_permissions.delete(callback_id))
78
+ queue << "allow"
79
+ return
80
+ end
81
+ elsif data && data.include?(":deny")
82
+ callback_id = data.sub(":deny", "")
83
+ if (queue = @pending_permissions.delete(callback_id))
84
+ queue << "deny"
85
+ return
86
+ end
87
+ end
88
+
89
+ # Forward all other callbacks to the engine's callback handler
90
+ @callback_handler&.call(
91
+ chat_id: chat_id,
92
+ user_id: user_id,
93
+ data: data,
94
+ callback_query_id: cq_id
95
+ )
96
+ rescue => e
97
+ # Silently handle callback errors
98
+ end
99
+
100
+ private
101
+
102
+ def log(msg)
103
+ if defined?(Ask::Coder::Log)
104
+ Ask::Coder::Log.debug(msg) rescue nil
105
+ else
106
+ STDERR.puts "[adapter] #{msg}"
107
+ end
108
+ end
109
+
110
+ def try_send_markdown(chat_id, text)
111
+ # Escape underscores and brackets so session IDs and data don't break Markdown
112
+ safe = text.gsub("_", "\\_").gsub("[", "\\[")
113
+ @bot&.send_message(chat_id: chat_id, text: safe, parse_mode: "Markdown")
114
+ rescue
115
+ nil # fall back to plain text
116
+ end
117
+
118
+ def try_edit_markdown(chat_id, message_id, text)
119
+ safe = text.gsub("_", "\\_").gsub("[", "\\[")
120
+ @bot&.edit_message(chat_id: chat_id, message_id: message_id, text: "⏳ #{safe}", parse_mode: "Markdown")
121
+ rescue
122
+ nil # fall back to plain text
123
+ end
124
+
125
+ public
126
+
127
+ # Set a handler for non-permission callbacks (session switching, etc.)
128
+ def set_callback_handler(handler)
129
+ @callback_handler = handler
130
+ end
131
+
132
+ # Send a rich card rendered as markdown + inline keyboards.
133
+ def send_card(chat_id, card)
134
+ log("[send_card] #{card.sections.length} sections")
135
+ lines = []
136
+ buttons = []
137
+
138
+ card.sections.each do |section|
139
+ lines << "**#{section.title}**" unless section.title.empty?
140
+ section.components.each do |comp|
141
+ case comp
142
+ when Card::TextBlock
143
+ lines << comp.content
144
+ when Card::Button
145
+ if comp.callback
146
+ buttons << [{ text: comp.label, callback_data: comp.callback }]
147
+ elsif comp.url
148
+ lines << "[#{comp.label}](#{comp.url})"
149
+ end
150
+ when Card::Table
151
+ lines << "| #{comp.header.join(' | ')} |"
152
+ lines << "| #{comp.header.map { |h| '—' * h.length }.join(' | ')} |"
153
+ comp.rows.each { |row| lines << "| #{row.join(' | ')} |" }
154
+ when Card::Divider
155
+ lines << "───"
156
+ end
157
+ end
158
+ lines << ""
159
+ end
160
+
161
+ text = lines.join("\n").strip
162
+ return if text.empty?
163
+
164
+ log("[send_card] bot=#{!@bot.nil?} buttons=#{buttons.length} text=#{text.length}ch")
165
+ if buttons.any?
166
+ @bot&.send_keyboard_message(chat_id: chat_id, text: text, buttons: buttons)
167
+ else
168
+ @bot&.send_message(chat_id: chat_id, text: text, parse_mode: "Markdown")
169
+ end
170
+ rescue => e
171
+ log("[send_card] ERROR: #{e.message[0..80]}")
172
+ nil
173
+ end
174
+
175
+ # Send an approval request with inline buttons.
176
+ # Returns the approval Queue for the engine to wait on.
177
+ def request_approval(chat_id, tool_name:, risk_level:, details:)
178
+ risk_label = {
179
+ "low" => "Low Risk", "medium" => "Medium Risk",
180
+ "high" => "High Risk", "critical" => "Critical Risk"
181
+ }.fetch(risk_level, risk_level)
182
+
183
+ callback_id = "perm:#{chat_id}:#{Time.now.to_i}"
184
+ text = ["🔐 Approval Required",
185
+ "Tool: #{tool_name} (#{risk_label})",
186
+ details].join("\n")
187
+
188
+ buttons = [
189
+ [{ text: "✅ Approve", callback_data: "#{callback_id}:allow" },
190
+ { text: "❌ Deny", callback_data: "#{callback_id}:deny" }]
191
+ ]
192
+
193
+ queue = Queue.new
194
+ @pending_permissions[callback_id] = queue
195
+
196
+ @bot&.send_keyboard_message(
197
+ chat_id: chat_id, text: text, buttons: buttons
198
+ )
199
+
200
+ queue # Engine waits on this queue for the decision
201
+ rescue => e
202
+ nil
203
+ end
204
+
205
+ private
206
+
207
+ def handle_incoming(msg)
208
+ return unless @message_handler
209
+ return if msg[:is_group] && !group_triggered?(msg)
210
+
211
+ chat_id = msg[:chat_id]
212
+ user_id = msg[:user_id]
213
+ text = msg[:text]
214
+ return unless allowed?(chat_id, user_id, msg[:is_group])
215
+ return if text.nil? || text.strip.empty?
216
+
217
+ # Handle built-in commands
218
+ case text.strip
219
+ when "/id", "/start", "/new", "/sessions", "/status", "/projects", "/mode", "/help", "/models", "/goal", "/sync"
220
+ handle_command(chat_id, user_id, text.strip)
221
+ return
222
+ end
223
+
224
+ @message_handler.call(
225
+ chat_id: chat_id,
226
+ user_id: user_id,
227
+ session_key: msg[:is_group] ? chat_id : user_id,
228
+ text: text.strip,
229
+ raw: msg
230
+ )
231
+ end
232
+
233
+ def handle_command(chat_id, user_id, command)
234
+ case command
235
+ when "/id"
236
+ @bot&.send_message(chat_id: chat_id, text: "Your Telegram user ID: `#{user_id}`")
237
+ when "/start"
238
+ @bot&.send_message(chat_id: chat_id, text: "🤖 Askoda bot active!\n\nSend /help to see all commands.\n/id - get your user ID\n/new - start fresh conversation")
239
+ when "/new", "/sessions", "/status", "/projects", "/mode", "/help", "/models", "/goal", "/sync"
240
+ @message_handler&.call(
241
+ chat_id: chat_id,
242
+ user_id: user_id,
243
+ session_key: chat_id < 0 ? chat_id : user_id,
244
+ text: command,
245
+ raw: nil
246
+ )
247
+ end
248
+ rescue => e
249
+ # Silently handle send errors during command responses
250
+ end
251
+
252
+ def allowed?(chat_id, user_id, is_group)
253
+ if is_group
254
+ @allowed_chats.empty? || @allowed_chats.include?(chat_id)
255
+ else
256
+ @allowed_users.empty? || @allowed_users.include?(user_id)
257
+ end
258
+ end
259
+
260
+ def group_triggered?(msg)
261
+ text = msg[:text] || ""
262
+ return false unless @bot_user_id
263
+
264
+ raw = msg[:raw]
265
+ return false unless raw.respond_to?(:reply_to_message) || raw.respond_to?(:entities)
266
+
267
+ replied = raw.respond_to?(:reply_to_message) ? raw.reply_to_message : nil
268
+ return true if replied && replied.respond_to?(:from) && replied.from.respond_to?(:id) &&
269
+ replied.from.id == @bot_user_id
270
+
271
+ entities = raw.respond_to?(:entities) ? raw.entities : nil
272
+ return false unless entities.respond_to?(:any?)
273
+
274
+ entities.any? do |ent|
275
+ next false unless ent.respond_to?(:type) && ent.type == "mention"
276
+ next false unless ent.respond_to?(:offset) && ent.respond_to?(:length)
277
+ mention = text[ent.offset, ent.length]
278
+ mention && mention.downcase == "@#{@bot_user_id}".downcase
279
+ end
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "telegram/bot"
4
+
5
+ module Ask
6
+ module ChannelProviders
7
+ module Telegram
8
+ # Polling-based Telegram bot client.
9
+ #
10
+ # Receives messages via getUpdates polling and wraps the telegram-bot-ruby
11
+ # client with a simpler interface.
12
+ #
13
+ # @example
14
+ # bot = Ask::ChannelProviders::Telegram::Bot.new(token: "123:ABC")
15
+ # bot.on_message { |msg| puts msg[:text] }
16
+ # bot.start
17
+ # bot.send_message(chat_id: 123, text: "Hello")
18
+ # bot.stop
19
+ class Bot
20
+ attr_reader :token, :bot_user_id
21
+ attr_accessor :on_callback
22
+
23
+ # @param token [String] Telegram bot token from @BotFather
24
+ MAX_DEDUP_CACHE = 500
25
+
26
+ def initialize(token:)
27
+ @token = token
28
+ @client = ::Telegram::Bot::Client.new(token)
29
+ @running = false
30
+ @on_message = nil
31
+ @on_callback = nil
32
+ @poll_thread = nil
33
+ @last_update_id = 0
34
+ @bot_user_id = nil
35
+ @processed_updates = [] # ring buffer of recent update_ids
36
+ end
37
+
38
+ # Start polling for messages.
39
+ #
40
+ # @param interval [Float] seconds between polls
41
+ # @param on_message [Proc] called with { chat_id:, user_id:, text:, date:, raw: }
42
+ # @param on_callback [Proc] called with { callback_query_id:, data:, chat_id:, user_id:, message_id: }
43
+ def start(interval: 1.0, &on_message)
44
+ @on_message = on_message
45
+ @running = true
46
+ fetch_bot_info
47
+ @poll_thread = Thread.new { poll_loop(interval) }
48
+ end
49
+
50
+ # Stop polling.
51
+ def stop
52
+ @running = false
53
+ @poll_thread&.join(5) rescue nil
54
+ @poll_thread = nil
55
+ end
56
+
57
+ def running?
58
+ @running
59
+ end
60
+
61
+ # Send a text message to a chat.
62
+ def log(msg)
63
+ if defined?(Ask::Coder::Log)
64
+ Ask::Coder::Log.debug(msg) rescue nil
65
+ elsif ENV["DEBUG"] == "1"
66
+ STDERR.puts "[bot] #{msg}"
67
+ end
68
+ end
69
+
70
+ def send_message(chat_id:, text:, parse_mode: nil)
71
+ log("[send] msg #{text.length}ch#{parse_mode ? " (#{parse_mode})" : ""}")
72
+ params = { chat_id: chat_id, text: text }
73
+ params[:parse_mode] = parse_mode if parse_mode
74
+ @client.api.send_message(params)
75
+ log("[send] ✅ delivered")
76
+ rescue ::Telegram::Bot::Exceptions::Base => e
77
+ log("[send] FAIL: #{e.message[0..80]}")
78
+ raise Ask::ChannelProviders::APIError, "Telegram API error: #{e.message}"
79
+ end
80
+
81
+ # Send a message with inline keyboard buttons.
82
+ def send_keyboard_message(chat_id:, text:, buttons:, parse_mode: nil)
83
+ n = buttons.flatten.length
84
+ log("[send] #{n} btn(s): #{buttons.flatten.map { |b| b[:text] }.inspect}")
85
+ keyboard_rows = buttons.map do |row|
86
+ row.map { |btn| ::Telegram::Bot::Types::InlineKeyboardButton.new(**btn) }
87
+ end
88
+ reply_markup = ::Telegram::Bot::Types::InlineKeyboardMarkup.new(inline_keyboard: keyboard_rows)
89
+ params = { chat_id: chat_id, text: text, reply_markup: reply_markup }
90
+ params[:parse_mode] = parse_mode if parse_mode
91
+ @client.api.send_message(params)
92
+ log("[send] ✅ delivered")
93
+ rescue ::Telegram::Bot::Exceptions::Base => e
94
+ log("[send] KEYBOARD FAIL: #{e.message[0..120]}")
95
+ # Fallback: send plain text without buttons
96
+ send_message(chat_id: chat_id, text: text)
97
+ end
98
+
99
+ # Answer a callback query (required by Telegram, removes the loading spinner).
100
+ def answer_callback_query(callback_query_id:, text: nil)
101
+ log("[send] callback answer")
102
+ params = { callback_query_id: callback_query_id }
103
+ params[:text] = text if text
104
+ @client.api.answer_callback_query(params)
105
+ rescue ::Telegram::Bot::Exceptions::Base
106
+ # Silently ignore errors
107
+ end
108
+
109
+ # Edit a message (for streaming updates).
110
+ def edit_message(chat_id:, message_id:, text:, parse_mode: nil)
111
+ log("[send] edit msg #{message_id} #{text.length}ch")
112
+ params = { chat_id: chat_id, message_id: message_id, text: text }
113
+ params[:parse_mode] = parse_mode if parse_mode
114
+ @client.api.edit_message_text(params)
115
+ rescue ::Telegram::Bot::Exceptions::Base => e
116
+ desc = e.respond_to?(:data) ? (e.data[:description] || e.data["description"] || e.message) : e.message
117
+ raise Ask::ChannelProviders::APIError, e.message unless desc.include?("message is not modified")
118
+ end
119
+
120
+ # Delete a message.
121
+ def delete_message(chat_id:, message_id:)
122
+ @client.api.delete_message(chat_id: chat_id, message_id: message_id)
123
+ rescue ::Telegram::Bot::Exceptions::Base
124
+ # Silently ignore if already deleted
125
+ end
126
+
127
+ # Get bot info.
128
+ def me
129
+ @me ||= @client.api.get_me
130
+ rescue ::Telegram::Bot::Exceptions::Base => e
131
+ raise Ask::ChannelProviders::APIError, "Failed to get bot info: #{e.message}"
132
+ end
133
+
134
+ private
135
+
136
+ def fetch_bot_info
137
+ info = me
138
+ @bot_user_id = info.id if info
139
+ rescue => e
140
+ # Bot info unavailable (e.g., test tokens), continue without it
141
+ end
142
+
143
+ def poll_loop(interval)
144
+ while @running
145
+ begin
146
+ fetch_updates
147
+ rescue => e
148
+ # Log error, continue polling
149
+ end
150
+ sleep(interval)
151
+ end
152
+ end
153
+
154
+ def fetch_updates
155
+ params = { offset: @last_update_id + 1, timeout: 5 }
156
+ updates = @client.api.get_updates(params)
157
+ updates = Array(updates)
158
+ updates.each do |update|
159
+ process_update(update)
160
+ uid = update.respond_to?(:update_id) ? update.update_id : nil
161
+ @last_update_id = uid if uid && uid >= @last_update_id
162
+ end
163
+ rescue ::Telegram::Bot::Exceptions::Base => e
164
+ raise Ask::ChannelProviders::APIError, "Polling error: #{e.message}"
165
+ end
166
+
167
+ def process_update(update)
168
+ # Dedup: skip already-processed update_ids
169
+ uid = update.respond_to?(:update_id) ? update.update_id : nil
170
+ if uid && @processed_updates.include?(uid)
171
+ return
172
+ end
173
+ @processed_updates << uid if uid
174
+ @processed_updates.shift if @processed_updates.length > MAX_DEDUP_CACHE
175
+
176
+ # Handle callback queries (inline keyboard button clicks)
177
+ if update.respond_to?(:callback_query) && update.callback_query
178
+ handle_callback_query(update.callback_query)
179
+ return
180
+ end
181
+
182
+ message = extract_message(update)
183
+ return unless message
184
+
185
+ chat = message.respond_to?(:chat) ? message.chat : nil
186
+ from = message.respond_to?(:from) ? message.from : nil
187
+ text = message.respond_to?(:text) ? message.text : nil
188
+ return unless chat && text
189
+
190
+ chat_type = chat.respond_to?(:type) ? chat.type.to_s : ""
191
+
192
+ msg = {
193
+ chat_id: chat.respond_to?(:id) ? chat.id : nil,
194
+ user_id: from.respond_to?(:id) ? from.id : nil,
195
+ text: text,
196
+ date: message.respond_to?(:date) ? message.date : nil,
197
+ is_group: %w[group supergroup].include?(chat_type),
198
+ message_id: message.respond_to?(:message_id) ? message.message_id : nil,
199
+ raw: message
200
+ }
201
+
202
+ @on_message&.call(msg)
203
+ end
204
+
205
+ def handle_callback_query(cq)
206
+ return unless @on_callback
207
+ data = cq.respond_to?(:data) ? cq.data : nil
208
+ msg = cq.respond_to?(:message) ? cq.message : nil
209
+ from = cq.respond_to?(:from) ? cq.from : nil
210
+ return unless data && msg && from
211
+
212
+ @on_callback.call(
213
+ callback_query_id: cq.respond_to?(:id) ? cq.id : nil,
214
+ data: data,
215
+ chat_id: msg.respond_to?(:chat) && msg.chat.respond_to?(:id) ? msg.chat.id : nil,
216
+ user_id: from.respond_to?(:id) ? from.id : nil,
217
+ message_id: msg.respond_to?(:message_id) ? msg.message_id : nil,
218
+ raw: cq
219
+ )
220
+ end
221
+
222
+ def extract_message(update)
223
+ m = update.respond_to?(:message) ? update.message : nil
224
+ m ||= update.respond_to?(:channel_post) ? update.channel_post : nil
225
+ m ||= update.respond_to?(:edited_message) ? update.edited_message : nil
226
+ m
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "telegram/bot"
4
+ require_relative "telegram/adapter"
5
+
6
+ module Ask
7
+ module ChannelProviders
8
+ # Telegram channel provider for the ask-coder ecosystem.
9
+ #
10
+ # Provides a polling-based Telegram bot client and a ChannelAdapter
11
+ # implementation that plugs into Ask::Coder.
12
+ module Telegram
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module ChannelProviders
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "channel_providers/version"
4
+ require_relative "channel_providers/card"
5
+ require_relative "channel_providers/adapter"
6
+ require_relative "channel_providers/telegram"
7
+
8
+ module Ask
9
+ module ChannelProviders
10
+ class Error < StandardError; end
11
+ class ConfigurationError < Error; end
12
+ class APIError < Error; end
13
+ end
14
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ask/channel_providers"
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ask-channel-providers
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kaka Ruto
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: telegram-bot-ruby
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.25'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.25'
40
+ - !ruby/object:Gem::Dependency
41
+ name: mocha
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.1'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: simplecov
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.22'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.22'
82
+ description: Channel adapter interface and implementations (Telegram, Discord, Slack)
83
+ for connecting AI coding agents to messaging platforms.
84
+ email:
85
+ - kaka@myrrlabs.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - lib/ask-channel-providers.rb
91
+ - lib/ask/channel_providers.rb
92
+ - lib/ask/channel_providers/adapter.rb
93
+ - lib/ask/channel_providers/card.rb
94
+ - lib/ask/channel_providers/telegram.rb
95
+ - lib/ask/channel_providers/telegram/adapter.rb
96
+ - lib/ask/channel_providers/telegram/bot.rb
97
+ - lib/ask/channel_providers/version.rb
98
+ homepage: https://github.com/ask-rb/ask-channel-providers
99
+ licenses:
100
+ - MIT
101
+ metadata:
102
+ homepage_uri: https://github.com/ask-rb/ask-channel-providers
103
+ source_code_uri: https://github.com/ask-rb/ask-channel-providers
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '3.2'
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 4.0.3
119
+ specification_version: 4
120
+ summary: Messaging channel adapters for the ask-rb ecosystem
121
+ test_files: []