chat_sdk-telegram 0.2.1

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: 9c91f00043b554f53ef31b9c1c73540d7495ed60313802c7f4db3f8e41f6729e
4
+ data.tar.gz: 9f081cdebba34e0ecfad397fc72d031770b8ce92ae2cdd0c6c694a3216e7af31
5
+ SHA512:
6
+ metadata.gz: 7e9b5f9eab5c771f56fab8e708d26ffe14a56415add30a4aab5a9b04a61ffc5f64284358c8e8ccf913bb28525e1ccf183352159eb7111572912737c8f0dadf9a
7
+ data.tar.gz: 5da8589bb74c1279e6adc239e385aab47fe4e36536b7cd2030e00b443891a9af799f6b7fe883f10459a72a5f7a4df316acd262279e50dcb72a5b054b7d796037
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/utils"
4
+
5
+ module ChatSDK
6
+ module Telegram
7
+ class Adapter < ChatSDK::Adapter::Base
8
+ capabilities :edit_messages, :delete_messages, :reactions, :file_uploads,
9
+ :typing_indicator, :streaming_edit, :direct_messages
10
+
11
+ attr_reader :client
12
+
13
+ def initialize(bot_token: nil, secret_token: nil, bot_username: nil)
14
+ @bot_token = bot_token || ENV["TELEGRAM_BOT_TOKEN"]
15
+ @secret_token = secret_token || ENV["TELEGRAM_WEBHOOK_SECRET_TOKEN"]
16
+ @bot_username = bot_username || ENV["TELEGRAM_BOT_USERNAME"]
17
+
18
+ raise ChatSDK::ConfigurationError, "Telegram bot_token required" unless @bot_token
19
+
20
+ @client = ApiClient.new(@bot_token)
21
+ @renderer = KeyboardRenderer.new
22
+ end
23
+
24
+ def name
25
+ :telegram
26
+ end
27
+
28
+ # Inbound
29
+ def verify_request!(rack_request)
30
+ return true unless @secret_token
31
+
32
+ header_token = rack_request.get_header("HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN")
33
+
34
+ unless header_token
35
+ raise ChatSDK::SignatureVerificationError, "Missing Telegram secret token header"
36
+ end
37
+
38
+ unless Rack::Utils.secure_compare(header_token, @secret_token)
39
+ raise ChatSDK::SignatureVerificationError, "Invalid Telegram secret token"
40
+ end
41
+
42
+ true
43
+ end
44
+
45
+ def ack_response(_rack_request)
46
+ nil
47
+ end
48
+
49
+ def parse_events(rack_request)
50
+ body = rack_request.body.read
51
+ rack_request.body.rewind
52
+
53
+ payload = JSON.parse(body)
54
+ EventParser.parse(payload, bot_username: @bot_username)
55
+ rescue JSON::ParserError
56
+ []
57
+ end
58
+
59
+ # Outbound
60
+ def post_message(channel_id:, message:, thread_id: nil)
61
+ text, reply_markup = prepare_message_payload(message)
62
+
63
+ result = @client.send_message(
64
+ chat_id: channel_id,
65
+ text: text,
66
+ reply_markup: reply_markup,
67
+ reply_to_message_id: thread_id
68
+ )
69
+
70
+ parse_telegram_message(result, channel_id)
71
+ end
72
+
73
+ def edit_message(channel_id:, message_id:, message:)
74
+ text, reply_markup = prepare_message_payload(message)
75
+
76
+ @client.edit_message_text(
77
+ chat_id: channel_id,
78
+ message_id: message_id,
79
+ text: text,
80
+ reply_markup: reply_markup
81
+ )
82
+ end
83
+
84
+ def delete_message(channel_id:, message_id:)
85
+ @client.delete_message(chat_id: channel_id, message_id: message_id)
86
+ end
87
+
88
+ def upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil)
89
+ @client.send_document(
90
+ chat_id: channel_id,
91
+ document: io,
92
+ filename: filename,
93
+ caption: comment,
94
+ reply_to_message_id: thread_id
95
+ )
96
+ end
97
+
98
+ def add_reaction(channel_id:, message_id:, emoji:)
99
+ @client.set_message_reaction(
100
+ chat_id: channel_id,
101
+ message_id: message_id,
102
+ reaction: [{"type" => "emoji", "emoji" => emoji}]
103
+ )
104
+ end
105
+
106
+ def remove_reaction(channel_id:, message_id:, emoji:) # rubocop:disable Lint/UnusedMethodArgument
107
+ @client.set_message_reaction(
108
+ chat_id: channel_id,
109
+ message_id: message_id,
110
+ reaction: []
111
+ )
112
+ end
113
+
114
+ def open_dm(user_id)
115
+ user_id
116
+ end
117
+
118
+ def start_typing(channel_id:, thread_id: nil) # rubocop:disable Lint/UnusedMethodArgument
119
+ @client.send_chat_action(chat_id: channel_id, action: "typing")
120
+ end
121
+
122
+ def mention(user_id)
123
+ "[user](tg://user?id=#{user_id})"
124
+ end
125
+
126
+ def render(postable_message)
127
+ if postable_message.card?
128
+ @renderer.render(postable_message.card)
129
+ else
130
+ postable_message.text
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ def prepare_message_payload(message)
137
+ msg = ChatSDK::PostableMessage.from(message)
138
+ text = msg.text || msg.card&.fallback_text || ""
139
+ reply_markup = nil
140
+ if msg.card?
141
+ rendered = @renderer.render(msg.card)
142
+ text = rendered[:text] if rendered[:text] && !rendered[:text].empty?
143
+ reply_markup = rendered[:reply_markup]
144
+ end
145
+ [text, reply_markup]
146
+ end
147
+
148
+ def parse_telegram_message(data, channel_id)
149
+ ChatSDK::Message.new(
150
+ id: data["message_id"]&.to_s,
151
+ text: data["text"] || "",
152
+ author: ChatSDK::Author.new(
153
+ id: data.dig("from", "id")&.to_s || "bot",
154
+ name: data.dig("from", "username") || "bot",
155
+ platform: :telegram,
156
+ bot: true
157
+ ),
158
+ thread_id: data["message_id"]&.to_s,
159
+ channel_id: channel_id,
160
+ platform: :telegram,
161
+ raw: data
162
+ )
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Telegram
5
+ class ApiClient
6
+ BASE_URL = "https://api.telegram.org"
7
+
8
+ def initialize(bot_token)
9
+ @bot_token = bot_token
10
+ end
11
+
12
+ def send_message(chat_id:, text:, reply_markup: nil, reply_to_message_id: nil)
13
+ body = {"chat_id" => chat_id, "text" => text, "parse_mode" => "Markdown"}
14
+ body["reply_markup"] = reply_markup if reply_markup
15
+ body["reply_to_message_id"] = reply_to_message_id if reply_to_message_id
16
+ request(:post, "sendMessage", body)
17
+ end
18
+
19
+ def edit_message_text(chat_id:, message_id:, text:, reply_markup: nil)
20
+ body = {"chat_id" => chat_id, "message_id" => message_id, "text" => text, "parse_mode" => "Markdown"}
21
+ body["reply_markup"] = reply_markup if reply_markup
22
+ request(:post, "editMessageText", body)
23
+ end
24
+
25
+ def delete_message(chat_id:, message_id:)
26
+ request(:post, "deleteMessage", {"chat_id" => chat_id, "message_id" => message_id})
27
+ end
28
+
29
+ def send_document(chat_id:, document:, filename:, caption: nil, reply_to_message_id: nil)
30
+ payload = {
31
+ "chat_id" => chat_id,
32
+ "document" => Faraday::Multipart::FilePart.new(document, "application/octet-stream", filename)
33
+ }
34
+ payload["caption"] = caption if caption
35
+ payload["reply_to_message_id"] = reply_to_message_id if reply_to_message_id
36
+
37
+ response = upload_connection.post(api_path("sendDocument"), payload)
38
+ handle_response(response)
39
+ end
40
+
41
+ def set_message_reaction(chat_id:, message_id:, reaction:)
42
+ request(:post, "setMessageReaction", {
43
+ "chat_id" => chat_id,
44
+ "message_id" => message_id,
45
+ "reaction" => reaction
46
+ })
47
+ end
48
+
49
+ def send_chat_action(chat_id:, action:)
50
+ request(:post, "sendChatAction", {"chat_id" => chat_id, "action" => action})
51
+ end
52
+
53
+ private
54
+
55
+ def api_path(method)
56
+ "/bot#{@bot_token}/#{method}"
57
+ end
58
+
59
+ def connection
60
+ @connection ||= Faraday.new(url: BASE_URL) do |f|
61
+ f.request :json
62
+ f.response :json
63
+ f.adapter :net_http
64
+ end
65
+ end
66
+
67
+ def upload_connection
68
+ @upload_connection ||= Faraday.new(url: BASE_URL) do |f|
69
+ f.request :multipart
70
+ f.response :json
71
+ f.adapter :net_http
72
+ end
73
+ end
74
+
75
+ def request(method, api_method, body = nil)
76
+ response = connection.public_send(method, api_path(api_method)) do |req|
77
+ req.body = body if body && method != :get
78
+ end
79
+
80
+ handle_response(response)
81
+ end
82
+
83
+ def handle_response(response)
84
+ body = response.body
85
+
86
+ if response.success? && body.is_a?(Hash) && body["ok"]
87
+ return body["result"]
88
+ end
89
+
90
+ if response.status == 429
91
+ retry_after = body.is_a?(Hash) ? body.dig("parameters", "retry_after")&.to_i : nil
92
+ raise ChatSDK::RateLimitedError.new(
93
+ "Telegram API rate limited",
94
+ retry_after: retry_after,
95
+ status: response.status,
96
+ body: body,
97
+ adapter_name: :telegram
98
+ )
99
+ end
100
+
101
+ description = body.is_a?(Hash) ? body["description"] : response.status
102
+ raise ChatSDK::PlatformError.new(
103
+ "Telegram API error: #{description}",
104
+ status: response.status,
105
+ body: body,
106
+ adapter_name: :telegram
107
+ )
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Telegram
5
+ class EventParser
6
+ class << self
7
+ def parse(payload, bot_username: nil)
8
+ return [] unless payload.is_a?(Hash)
9
+
10
+ if payload["callback_query"]
11
+ parse_callback_query(payload["callback_query"])
12
+ elsif payload["message"]
13
+ parse_message(payload["message"], bot_username: bot_username)
14
+ else
15
+ []
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def parse_message(message, bot_username: nil)
22
+ return parse_bot_command(message, extract_user(message), message.dig("chat", "id")&.to_s, resolve_thread_id(message)) if bot_command?(message)
23
+
24
+ user = extract_user(message)
25
+ chat_id = message.dig("chat", "id")&.to_s
26
+ chat_type = message.dig("chat", "type")
27
+ text = message["text"] || ""
28
+ thread_id = resolve_thread_id(message)
29
+
30
+ msg = ChatSDK::Message.new(
31
+ id: message["message_id"]&.to_s,
32
+ text: text,
33
+ author: ChatSDK::Author.new(id: user[:id], name: user[:name], platform: :telegram, bot: false),
34
+ thread_id: thread_id,
35
+ channel_id: chat_id,
36
+ platform: :telegram,
37
+ raw: message
38
+ )
39
+
40
+ event_class = if mention?(text, bot_username)
41
+ ChatSDK::Events::Mention
42
+ elsif chat_type == "private"
43
+ ChatSDK::Events::DirectMessage
44
+ else
45
+ ChatSDK::Events::SubscribedMessage
46
+ end
47
+
48
+ [event_class.new(message: msg, thread_id: thread_id, channel_id: chat_id,
49
+ platform: :telegram, adapter_name: :telegram, raw: message)]
50
+ end
51
+
52
+ def parse_callback_query(callback)
53
+ user_info = callback["from"] || {}
54
+ message = callback["message"] || {}
55
+ chat_id = message.dig("chat", "id")&.to_s
56
+ message_id = message["message_id"]&.to_s
57
+ callback_data = callback["data"] || ""
58
+
59
+ action_id, value = callback_data.split(":", 2)
60
+ value ||= action_id
61
+
62
+ user = ChatSDK::Author.new(
63
+ id: user_info["id"]&.to_s || "unknown",
64
+ name: user_info["username"] || user_info["first_name"] || "unknown",
65
+ platform: :telegram,
66
+ bot: false
67
+ )
68
+
69
+ [ChatSDK::Events::Action.new(
70
+ action_id: action_id,
71
+ value: value,
72
+ user: user,
73
+ thread_id: message_id,
74
+ channel_id: chat_id,
75
+ platform: :telegram,
76
+ adapter_name: :telegram,
77
+ raw: callback
78
+ )]
79
+ end
80
+
81
+ def extract_user(message)
82
+ from = message["from"] || {}
83
+ {
84
+ id: from["id"]&.to_s || "unknown",
85
+ name: from["username"] || from["first_name"] || "unknown"
86
+ }
87
+ end
88
+
89
+ def resolve_thread_id(message)
90
+ reply = message.dig("reply_to_message", "message_id")
91
+ (reply || message["message_id"])&.to_s
92
+ end
93
+
94
+ def bot_command?(message)
95
+ entities = message["entities"]
96
+ return false unless entities.is_a?(Array)
97
+
98
+ entities.any? { |e| e["type"] == "bot_command" }
99
+ end
100
+
101
+ def mention?(text, bot_username)
102
+ return false unless bot_username
103
+
104
+ text.include?("@#{bot_username}")
105
+ end
106
+
107
+ def parse_bot_command(message, user, chat_id, thread_id)
108
+ text = message["text"] || ""
109
+ parts = text.split(" ", 2)
110
+ command = parts[0]&.split("@")&.first || ""
111
+ args = parts[1] || ""
112
+
113
+ [ChatSDK::Events::SlashCommand.new(
114
+ command: command,
115
+ text: args,
116
+ user_id: user[:id],
117
+ channel_id: chat_id,
118
+ trigger_id: message["message_id"]&.to_s,
119
+ platform: :telegram,
120
+ adapter_name: :telegram,
121
+ raw: message
122
+ )]
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Telegram
5
+ class KeyboardRenderer
6
+ def render(node)
7
+ case node.type
8
+ when :card then render_card(node)
9
+ else render_single(node)
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def render_card(node)
16
+ text_parts = []
17
+ keyboard_rows = []
18
+
19
+ text_parts << "*#{node.attributes[:title]}*" if node.attributes[:title]
20
+ text_parts << "_#{node.attributes[:subtitle]}_" if node.attributes[:subtitle]
21
+
22
+ node.children.each do |child|
23
+ case child.type
24
+ when :text
25
+ text_parts << child.attributes[:content]
26
+ when :divider
27
+ text_parts << "───"
28
+ when :image
29
+ text_parts << "[Image](#{child.attributes[:url]})"
30
+ when :fields
31
+ child.children.each do |field|
32
+ text_parts << "*#{field.attributes[:label]}*: #{field.attributes[:value]}"
33
+ end
34
+ when :section
35
+ render_section_into(child, text_parts, keyboard_rows)
36
+ when :actions
37
+ render_actions_into(child, keyboard_rows)
38
+ when :button
39
+ keyboard_rows << [render_button(child)]
40
+ when :link_button
41
+ keyboard_rows << [render_link_button(child)]
42
+ when :select
43
+ render_select_into(child, keyboard_rows)
44
+ end
45
+ end
46
+
47
+ result = {text: text_parts.join("\n")}
48
+ unless keyboard_rows.empty?
49
+ result[:reply_markup] = {"inline_keyboard" => keyboard_rows}
50
+ end
51
+ result
52
+ end
53
+
54
+ def render_section_into(node, text_parts, keyboard_rows)
55
+ text_parts << "*#{node.attributes[:title]}*" if node.attributes[:title]
56
+
57
+ node.children.each do |child|
58
+ case child.type
59
+ when :text
60
+ text_parts << child.attributes[:content]
61
+ when :fields
62
+ child.children.each do |field|
63
+ text_parts << "*#{field.attributes[:label]}*: #{field.attributes[:value]}"
64
+ end
65
+ when :actions
66
+ render_actions_into(child, keyboard_rows)
67
+ when :button
68
+ keyboard_rows << [render_button(child)]
69
+ when :link_button
70
+ keyboard_rows << [render_link_button(child)]
71
+ when :select
72
+ render_select_into(child, keyboard_rows)
73
+ end
74
+ end
75
+ end
76
+
77
+ def render_actions_into(node, keyboard_rows)
78
+ row = []
79
+ node.children.each do |child|
80
+ if child.type == :select
81
+ keyboard_rows << row unless row.empty?
82
+ row = []
83
+ render_select_into(child, keyboard_rows)
84
+ else
85
+ comp = render_component(child)
86
+ row << comp if comp
87
+ end
88
+ end
89
+ keyboard_rows << row unless row.empty?
90
+ end
91
+
92
+ def render_component(node)
93
+ case node.type
94
+ when :button then render_button(node)
95
+ when :link_button then render_link_button(node)
96
+ end
97
+ end
98
+
99
+ def render_button(node)
100
+ callback_data = if node.attributes[:value]
101
+ "#{node.attributes[:id]}:#{node.attributes[:value]}"
102
+ else
103
+ node.attributes[:id] || "button"
104
+ end
105
+
106
+ {"text" => node.attributes[:text], "callback_data" => callback_data}
107
+ end
108
+
109
+ def render_link_button(node)
110
+ {"text" => node.attributes[:text], "url" => node.attributes[:url]}
111
+ end
112
+
113
+ def render_select_into(node, keyboard_rows)
114
+ node.children.each do |opt|
115
+ callback_data = "#{node.attributes[:id]}:#{opt.attributes[:value]}"
116
+ keyboard_rows << [{"text" => opt.attributes[:text], "callback_data" => callback_data}]
117
+ end
118
+ end
119
+
120
+ def render_single(node)
121
+ case node.type
122
+ when :text
123
+ {text: node.attributes[:content]}
124
+ else
125
+ {text: node.fallback_text}
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "chat_sdk"
4
+ require "faraday"
5
+ require "faraday/net_http"
6
+ require "zeitwerk"
7
+
8
+ module ChatSDK
9
+ module Telegram
10
+ class << self
11
+ def loader
12
+ @loader ||= begin
13
+ loader = Zeitwerk::Loader.new
14
+ loader.tag = "chat_sdk-telegram"
15
+ loader.inflector.inflect("chat_sdk" => "ChatSDK")
16
+ loader.push_dir("#{__dir__}/telegram", namespace: ChatSDK::Telegram)
17
+ loader.setup
18
+ loader
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ ChatSDK::Telegram.loader
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: chat_sdk-telegram
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Rootly
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: chat_sdk
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ description: Telegram Bot API adapter for the ChatSDK framework
41
+ email:
42
+ - eng@rootly.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/chat_sdk/telegram.rb
48
+ - lib/chat_sdk/telegram/adapter.rb
49
+ - lib/chat_sdk/telegram/api_client.rb
50
+ - lib/chat_sdk/telegram/event_parser.rb
51
+ - lib/chat_sdk/telegram/keyboard_renderer.rb
52
+ homepage: https://github.com/rootlyhq/chat-sdk
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ rubygems_mfa_required: 'true'
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.2.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 4.0.16
72
+ specification_version: 4
73
+ summary: Telegram adapter for ChatSDK
74
+ test_files: []