connector-ruby 0.1.1 → 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.
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectorRuby
4
+ module Channels
5
+ class Messenger < Base
6
+ BASE_URL = "https://graph.facebook.com/v21.0/me/messages"
7
+
8
+ def initialize(page_access_token: nil)
9
+ @page_access_token = page_access_token || ConnectorRuby.configuration.messenger_page_access_token
10
+ raise ConfigurationError, "Messenger page_access_token is required" unless @page_access_token
11
+ end
12
+
13
+ def send_text(to:, text:)
14
+ validate_send!(to: to, text: text)
15
+ payload = {
16
+ recipient: { id: to },
17
+ message: { text: text }
18
+ }
19
+ post_message(payload)
20
+ end
21
+
22
+ def send_buttons(to:, text:, buttons:)
23
+ validate_send!(to: to, text: text)
24
+ formatted = buttons.map do |btn|
25
+ { type: "postback", title: btn[:title], payload: btn[:id] }
26
+ end
27
+
28
+ payload = {
29
+ recipient: { id: to },
30
+ message: {
31
+ attachment: {
32
+ type: "template",
33
+ payload: {
34
+ template_type: "button",
35
+ text: text,
36
+ buttons: formatted
37
+ }
38
+ }
39
+ }
40
+ }
41
+ post_message(payload)
42
+ end
43
+
44
+ def send_image(to:, url:, caption: nil)
45
+ validate_send!(to: to)
46
+ payload = {
47
+ recipient: { id: to },
48
+ message: {
49
+ attachment: {
50
+ type: "image",
51
+ payload: { url: url, is_reusable: true }
52
+ }
53
+ }
54
+ }
55
+ post_message(payload)
56
+ end
57
+
58
+ def send_quick_replies(to:, text:, replies:)
59
+ validate_send!(to: to, text: text)
60
+ formatted = replies.map do |r|
61
+ { content_type: "text", title: r[:title], payload: r[:id] }
62
+ end
63
+
64
+ payload = {
65
+ recipient: { id: to },
66
+ message: { text: text, quick_replies: formatted }
67
+ }
68
+ post_message(payload)
69
+ end
70
+
71
+ def self.parse_webhook(body)
72
+ data = body.is_a?(String) ? JSON.parse(body) : body
73
+
74
+ entry = data.dig("entry", 0)
75
+ return nil unless entry
76
+
77
+ messaging = entry.dig("messaging", 0)
78
+ return nil unless messaging
79
+
80
+ if messaging["message"]
81
+ parse_message(messaging)
82
+ elsif messaging["postback"]
83
+ parse_postback(messaging)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def post_message(payload)
90
+ http_client.post(BASE_URL, body: payload, headers: auth_headers)
91
+ end
92
+
93
+ def auth_headers
94
+ { "Authorization" => "Bearer #{@page_access_token}" }
95
+ end
96
+
97
+ def validate_send!(to:, text: nil)
98
+ raise ConnectorRuby::Error, "Recipient 'to' cannot be nil or empty" if to.nil? || to.to_s.strip.empty?
99
+ if text
100
+ raise ConnectorRuby::Error, "Text cannot be nil or empty" if text.nil? || text.to_s.strip.empty?
101
+ raise ConnectorRuby::Error, "Text exceeds 2000 character limit" if text.length > 2000
102
+ end
103
+ end
104
+
105
+ def self.parse_message(messaging)
106
+ msg = messaging["message"]
107
+ sender = messaging.dig("sender", "id")
108
+
109
+ Event.new(
110
+ type: :message,
111
+ channel: :messenger,
112
+ from: sender,
113
+ to: messaging.dig("recipient", "id"),
114
+ text: msg["text"],
115
+ timestamp: messaging["timestamp"] ? Time.at(messaging["timestamp"].to_i / 1000) : nil,
116
+ message_id: msg["mid"],
117
+ metadata: {
118
+ is_echo: msg["is_echo"],
119
+ quick_reply_payload: msg.dig("quick_reply", "payload")
120
+ }
121
+ )
122
+ end
123
+
124
+ def self.parse_postback(messaging)
125
+ postback = messaging["postback"]
126
+ sender = messaging.dig("sender", "id")
127
+
128
+ Event.new(
129
+ type: :callback,
130
+ channel: :messenger,
131
+ from: sender,
132
+ to: messaging.dig("recipient", "id"),
133
+ text: postback["payload"],
134
+ timestamp: messaging["timestamp"] ? Time.at(messaging["timestamp"].to_i / 1000) : nil,
135
+ metadata: {
136
+ title: postback["title"]
137
+ }
138
+ )
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectorRuby
4
+ module Channels
5
+ class Slack < Base
6
+ BASE_URL = "https://slack.com/api"
7
+
8
+ def initialize(bot_token: nil)
9
+ @bot_token = bot_token || ConnectorRuby.configuration.slack_bot_token
10
+ raise ConfigurationError, "Slack bot_token is required" unless @bot_token
11
+ end
12
+
13
+ def send_text(channel:, text:)
14
+ validate_send!(channel: channel, text: text)
15
+ payload = { channel: channel, text: text }
16
+ api_call("chat.postMessage", payload)
17
+ end
18
+
19
+ def send_buttons(channel:, text:, buttons:)
20
+ validate_send!(channel: channel, text: text)
21
+ actions = buttons.map do |btn|
22
+ {
23
+ type: "button",
24
+ text: { type: "plain_text", text: btn[:title] },
25
+ action_id: btn[:id],
26
+ value: btn[:id]
27
+ }
28
+ end
29
+
30
+ payload = {
31
+ channel: channel,
32
+ text: text,
33
+ blocks: [
34
+ { type: "section", text: { type: "mrkdwn", text: text } },
35
+ { type: "actions", elements: actions }
36
+ ]
37
+ }
38
+ api_call("chat.postMessage", payload)
39
+ end
40
+
41
+ def send_image(channel:, url:, caption: nil)
42
+ validate_send!(channel: channel)
43
+ blocks = [
44
+ {
45
+ type: "image",
46
+ image_url: url,
47
+ alt_text: caption || "image"
48
+ }
49
+ ]
50
+ blocks.first[:title] = { type: "plain_text", text: caption } if caption
51
+
52
+ payload = { channel: channel, text: caption || "Image", blocks: blocks }
53
+ api_call("chat.postMessage", payload)
54
+ end
55
+
56
+ def send_blocks(channel:, text:, blocks:)
57
+ validate_send!(channel: channel)
58
+ payload = { channel: channel, text: text, blocks: blocks }
59
+ api_call("chat.postMessage", payload)
60
+ end
61
+
62
+ def self.parse_webhook(body)
63
+ data = body.is_a?(String) ? JSON.parse(body) : body
64
+
65
+ # URL verification challenge
66
+ return { challenge: data["challenge"] } if data["type"] == "url_verification"
67
+
68
+ event = data["event"]
69
+ return nil unless event
70
+
71
+ case event["type"]
72
+ when "message"
73
+ return nil if event["subtype"] # skip bot messages, edits, etc.
74
+ parse_message(event)
75
+ when "app_mention"
76
+ parse_message(event)
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def api_call(method, payload)
83
+ http_client.post("#{BASE_URL}/#{method}", body: payload, headers: auth_headers)
84
+ end
85
+
86
+ def auth_headers
87
+ { "Authorization" => "Bearer #{@bot_token}" }
88
+ end
89
+
90
+ def validate_send!(channel:, text: nil)
91
+ raise ConnectorRuby::Error, "Recipient 'channel' cannot be nil or empty" if channel.nil? || channel.to_s.strip.empty?
92
+ if text
93
+ raise ConnectorRuby::Error, "Text cannot be nil or empty" if text.nil? || text.to_s.strip.empty?
94
+ end
95
+ end
96
+
97
+ def self.parse_message(event)
98
+ Event.new(
99
+ type: :message,
100
+ channel: :slack,
101
+ from: event["user"],
102
+ text: event["text"],
103
+ timestamp: event["ts"] ? Time.at(event["ts"].to_f) : nil,
104
+ message_id: event["ts"],
105
+ metadata: {
106
+ channel_id: event["channel"],
107
+ channel_type: event["channel_type"],
108
+ team: event["team"],
109
+ thread_ts: event["thread_ts"]
110
+ }
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end
@@ -38,6 +38,24 @@ module ConnectorRuby
38
38
  api_call("sendPhoto", payload)
39
39
  end
40
40
 
41
+ def send_document(chat_id:, url:, caption: nil)
42
+ validate_send!(chat_id: chat_id)
43
+ payload = { chat_id: chat_id, document: url }
44
+ payload[:caption] = caption if caption
45
+ api_call("sendDocument", payload)
46
+ end
47
+
48
+ def send_location(chat_id:, latitude:, longitude:)
49
+ validate_send!(chat_id: chat_id)
50
+ payload = { chat_id: chat_id, latitude: latitude, longitude: longitude }
51
+ api_call("sendLocation", payload)
52
+ end
53
+
54
+ def send_typing(chat_id:)
55
+ validate_send!(chat_id: chat_id)
56
+ api_call("sendChatAction", { chat_id: chat_id, action: "typing" })
57
+ end
58
+
41
59
  def self.parse_webhook(body)
42
60
  data = body.is_a?(String) ? JSON.parse(body) : body
43
61
 
@@ -57,6 +57,103 @@ module ConnectorRuby
57
57
  post_message(payload)
58
58
  end
59
59
 
60
+ def send_template(to:, template_name:, language: "en", components: [])
61
+ validate_send!(to: to)
62
+ payload = {
63
+ messaging_product: "whatsapp",
64
+ to: to,
65
+ type: "template",
66
+ template: {
67
+ name: template_name,
68
+ language: { code: language },
69
+ components: components
70
+ }
71
+ }
72
+ post_message(payload)
73
+ end
74
+
75
+ def send_document(to:, url:, filename: nil, caption: nil)
76
+ validate_send!(to: to)
77
+ doc = { link: url }
78
+ doc[:filename] = filename if filename
79
+ doc[:caption] = caption if caption
80
+
81
+ payload = {
82
+ messaging_product: "whatsapp",
83
+ to: to,
84
+ type: "document",
85
+ document: doc
86
+ }
87
+ post_message(payload)
88
+ end
89
+
90
+ def send_location(to:, latitude:, longitude:, name: nil, address: nil)
91
+ validate_send!(to: to)
92
+ location = { latitude: latitude, longitude: longitude }
93
+ location[:name] = name if name
94
+ location[:address] = address if address
95
+
96
+ payload = {
97
+ messaging_product: "whatsapp",
98
+ to: to,
99
+ type: "location",
100
+ location: location
101
+ }
102
+ post_message(payload)
103
+ end
104
+
105
+ def send_contact(to:, name:, phone:)
106
+ validate_send!(to: to)
107
+ payload = {
108
+ messaging_product: "whatsapp",
109
+ to: to,
110
+ type: "contacts",
111
+ contacts: [{
112
+ name: { formatted_name: name },
113
+ phones: [{ phone: phone }]
114
+ }]
115
+ }
116
+ post_message(payload)
117
+ end
118
+
119
+ def send_reaction(to:, message_id:, emoji:)
120
+ validate_send!(to: to)
121
+ payload = {
122
+ messaging_product: "whatsapp",
123
+ to: to,
124
+ type: "reaction",
125
+ reaction: { message_id: message_id, emoji: emoji }
126
+ }
127
+ post_message(payload)
128
+ end
129
+
130
+ def send_list(to:, body:, button_text:, sections:)
131
+ validate_send!(to: to, text: body)
132
+ payload = {
133
+ messaging_product: "whatsapp",
134
+ to: to,
135
+ type: "interactive",
136
+ interactive: {
137
+ type: "list",
138
+ body: { text: body },
139
+ action: {
140
+ button: button_text,
141
+ sections: sections
142
+ }
143
+ }
144
+ }
145
+ post_message(payload)
146
+ end
147
+
148
+ def mark_as_read(message_id:)
149
+ payload = {
150
+ messaging_product: "whatsapp",
151
+ status: "read",
152
+ message_id: message_id
153
+ }
154
+ post_message(payload)
155
+ end
156
+
60
157
  def self.parse_webhook(body, signature: nil)
61
158
  data = body.is_a?(String) ? JSON.parse(body) : body
62
159
 
@@ -4,6 +4,10 @@ module ConnectorRuby
4
4
  class Configuration
5
5
  attr_accessor :whatsapp_phone_number_id, :whatsapp_access_token,
6
6
  :telegram_bot_token,
7
+ :messenger_page_access_token,
8
+ :line_channel_access_token,
9
+ :slack_bot_token,
10
+ :livechat_pat, :livechat_region,
7
11
  :http_timeout, :http_retries, :http_open_timeout,
8
12
  :on_request, :on_response, :on_error
9
13
 
@@ -11,6 +15,11 @@ module ConnectorRuby
11
15
  @whatsapp_phone_number_id = nil
12
16
  @whatsapp_access_token = nil
13
17
  @telegram_bot_token = nil
18
+ @messenger_page_access_token = nil
19
+ @line_channel_access_token = nil
20
+ @slack_bot_token = nil
21
+ @livechat_pat = nil
22
+ @livechat_region = nil
14
23
  @http_timeout = 30
15
24
  @http_retries = 3
16
25
  @http_open_timeout = 10
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectorRuby
4
+ class DeliveryTracker
5
+ attr_reader :entries
6
+
7
+ def initialize
8
+ @entries = {}
9
+ @callbacks = Hash.new { |h, k| h[k] = [] }
10
+ end
11
+
12
+ def track(message_id, metadata: {})
13
+ @entries[message_id] = {
14
+ status: :sent,
15
+ sent_at: Time.now,
16
+ metadata: metadata,
17
+ history: [{ status: :sent, at: Time.now }]
18
+ }
19
+ end
20
+
21
+ def update(message_id, status:)
22
+ entry = @entries[message_id]
23
+ return nil unless entry
24
+
25
+ entry[:status] = status.to_sym
26
+ entry[:history] << { status: status.to_sym, at: Time.now }
27
+
28
+ fire(status.to_sym, message_id, entry)
29
+ entry
30
+ end
31
+
32
+ def status(message_id)
33
+ @entries.dig(message_id, :status)
34
+ end
35
+
36
+ def on(status, &block)
37
+ @callbacks[status.to_sym] << block
38
+ end
39
+
40
+ def pending
41
+ @entries.select { |_, v| v[:status] == :sent }
42
+ end
43
+
44
+ def delivered
45
+ @entries.select { |_, v| v[:status] == :delivered }
46
+ end
47
+
48
+ def read
49
+ @entries.select { |_, v| v[:status] == :read }
50
+ end
51
+
52
+ private
53
+
54
+ def fire(status, message_id, entry)
55
+ @callbacks[status].each { |cb| cb.call(message_id, entry) }
56
+ end
57
+ end
58
+ end
@@ -4,9 +4,14 @@ module ConnectorRuby
4
4
  class Message
5
5
  attr_reader :type, :to, :text, :buttons, :image_url, :caption, :metadata
6
6
 
7
- TYPES = %i[text buttons image template].freeze
7
+ TYPES = %i[text buttons image template document location contact reaction list].freeze
8
8
 
9
- def initialize(type:, to:, text: nil, buttons: nil, image_url: nil, caption: nil, metadata: {})
9
+ attr_reader :document_url, :filename, :latitude, :longitude,
10
+ :location_name, :address, :phone, :contact_name,
11
+ :template_name, :language, :components,
12
+ :emoji, :sections, :button_text
13
+
14
+ def initialize(type:, to:, text: nil, buttons: nil, image_url: nil, caption: nil, metadata: {}, **extra)
10
15
  @type = type
11
16
  @to = to
12
17
  @text = text
@@ -14,8 +19,10 @@ module ConnectorRuby
14
19
  @image_url = image_url
15
20
  @caption = caption
16
21
  @metadata = metadata
22
+ extra.each { |k, v| instance_variable_set(:"@#{k}", v) }
17
23
  end
18
24
 
25
+ # Factory methods
19
26
  def self.text(to:, text:)
20
27
  new(type: :text, to: to, text: text)
21
28
  end
@@ -28,6 +35,24 @@ module ConnectorRuby
28
35
  new(type: :image, to: to, image_url: url, caption: caption)
29
36
  end
30
37
 
38
+ def self.document(to:, url:, filename: nil, caption: nil)
39
+ new(type: :document, to: to, document_url: url, filename: filename, caption: caption)
40
+ end
41
+
42
+ def self.location(to:, latitude:, longitude:, name: nil, address: nil)
43
+ new(type: :location, to: to, latitude: latitude, longitude: longitude,
44
+ location_name: name, address: address)
45
+ end
46
+
47
+ def self.contact(to:, name:, phone:)
48
+ new(type: :contact, to: to, contact_name: name, phone: phone)
49
+ end
50
+
51
+ # Builder DSL
52
+ def self.build
53
+ Builder.new
54
+ end
55
+
31
56
  def to_h
32
57
  {
33
58
  type: @type,
@@ -39,5 +64,61 @@ module ConnectorRuby
39
64
  metadata: @metadata
40
65
  }.compact
41
66
  end
67
+
68
+ class Builder
69
+ def initialize
70
+ @attrs = { metadata: {} }
71
+ end
72
+
73
+ def to(recipient)
74
+ @attrs[:to] = recipient
75
+ self
76
+ end
77
+
78
+ def text(content)
79
+ @attrs[:type] = :text
80
+ @attrs[:text] = content
81
+ self
82
+ end
83
+
84
+ def buttons(btns)
85
+ @attrs[:type] = :buttons
86
+ @attrs[:buttons] = btns
87
+ self
88
+ end
89
+
90
+ def image(url, caption: nil)
91
+ @attrs[:type] = :image
92
+ @attrs[:image_url] = url
93
+ @attrs[:caption] = caption
94
+ self
95
+ end
96
+
97
+ def document(url, filename: nil)
98
+ @attrs[:type] = :document
99
+ @attrs[:document_url] = url
100
+ @attrs[:filename] = filename
101
+ self
102
+ end
103
+
104
+ def location(lat, lng, name: nil)
105
+ @attrs[:type] = :location
106
+ @attrs[:latitude] = lat
107
+ @attrs[:longitude] = lng
108
+ @attrs[:location_name] = name
109
+ self
110
+ end
111
+
112
+ def metadata(hash)
113
+ @attrs[:metadata] = hash
114
+ self
115
+ end
116
+
117
+ def build
118
+ raise ConnectorRuby::Error, "Message requires a recipient" unless @attrs[:to]
119
+ raise ConnectorRuby::Error, "Message requires a type" unless @attrs[:type]
120
+ Message.new(**@attrs)
121
+ end
122
+ end
42
123
  end
43
124
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ConnectorRuby
4
- VERSION = "0.1.1"
4
+ VERSION = "0.3.0"
5
5
  end