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 +7 -0
- data/lib/ask/channel_providers/adapter.rb +107 -0
- data/lib/ask/channel_providers/card.rb +84 -0
- data/lib/ask/channel_providers/telegram/adapter.rb +284 -0
- data/lib/ask/channel_providers/telegram/bot.rb +231 -0
- data/lib/ask/channel_providers/telegram.rb +15 -0
- data/lib/ask/channel_providers/version.rb +7 -0
- data/lib/ask/channel_providers.rb +14 -0
- data/lib/ask-channel-providers.rb +3 -0
- metadata +121 -0
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,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
|
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: []
|