connector-ruby 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +22 -38
- data/lib/connector_ruby/batch_sender.rb +44 -0
- data/lib/connector_ruby/channels/line.rb +119 -0
- data/lib/connector_ruby/channels/messenger.rb +142 -0
- data/lib/connector_ruby/channels/slack.rb +115 -0
- data/lib/connector_ruby/channels/telegram.rb +18 -0
- data/lib/connector_ruby/channels/whatsapp.rb +97 -0
- data/lib/connector_ruby/configuration.rb +6 -0
- data/lib/connector_ruby/delivery_tracker.rb +58 -0
- data/lib/connector_ruby/message.rb +83 -2
- data/lib/connector_ruby/version.rb +1 -1
- data/lib/connector_ruby.rb +8 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3564fee7248f47cf5a37285ef65601563ad281f509ac7e62a7a990219d81a87
|
|
4
|
+
data.tar.gz: 1edc94f66291c7d36116fb37af9d7546d3d275993e56ecdb49bac5b9afc1775e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b1f6ee12c630043c864c2a60e0684e388e50e2cbb53ce868377e6fb5254ce93fc2927dec99efb3841a960316a2924e8a47348d6dee1909f746098065973c3d3
|
|
7
|
+
data.tar.gz: 168d00e2d6bb2779f8e32a47224bd390a08e70cf13faa05eefcb44f8748aff2deb9e96b19e2a415bff468e3722122348537daae1d0a3e575252133fe781b913b
|
data/README.md
CHANGED
|
@@ -1,58 +1,42 @@
|
|
|
1
1
|
# connector-ruby
|
|
2
2
|
|
|
3
|
-
Unified channel messaging SDK for Ruby.
|
|
3
|
+
Unified channel messaging SDK for Ruby. Send and receive messages across WhatsApp and Telegram with a consistent API.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```ruby
|
|
8
|
-
gem "connector-ruby"
|
|
8
|
+
gem "connector-ruby"
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
## Supported Channels
|
|
12
|
-
|
|
13
|
-
- WhatsApp Cloud API
|
|
14
|
-
- Telegram Bot API
|
|
15
|
-
|
|
16
11
|
## Usage
|
|
17
12
|
|
|
18
|
-
### WhatsApp
|
|
19
|
-
|
|
20
13
|
```ruby
|
|
21
|
-
|
|
22
|
-
phone_number_id: "...",
|
|
23
|
-
access_token: "..."
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
client.send_text(to: "+62812...", text: "Hello!")
|
|
27
|
-
client.send_buttons(to: "+62812...", body: "Choose:", buttons: [
|
|
28
|
-
{ id: "opt1", title: "Option 1" },
|
|
29
|
-
{ id: "opt2", title: "Option 2" }
|
|
30
|
-
])
|
|
31
|
-
client.send_image(to: "+62812...", url: "https://...")
|
|
14
|
+
require "connector_ruby"
|
|
32
15
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
16
|
+
# WhatsApp
|
|
17
|
+
wa = ConnectorRuby::Channels::WhatsApp.new(
|
|
18
|
+
access_token: ENV["WHATSAPP_TOKEN"],
|
|
19
|
+
phone_number_id: ENV["WHATSAPP_PHONE_ID"]
|
|
20
|
+
)
|
|
21
|
+
wa.send_text(to: "+1234567890", text: "Hello!")
|
|
37
22
|
|
|
38
|
-
|
|
39
|
-
|
|
23
|
+
# Telegram
|
|
24
|
+
tg = ConnectorRuby::Channels::Telegram.new(bot_token: ENV["TELEGRAM_TOKEN"])
|
|
25
|
+
tg.send_text(to: "chat_id", text: "Hello!")
|
|
40
26
|
|
|
41
|
-
|
|
42
|
-
|
|
27
|
+
# Webhook verification
|
|
28
|
+
verifier = ConnectorRuby::WebhookVerifier.new(secret_token: "secret")
|
|
29
|
+
verifier.verify!(request_body, signature_header)
|
|
43
30
|
```
|
|
44
31
|
|
|
45
|
-
|
|
32
|
+
## Features
|
|
46
33
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
config.http_retries = 3
|
|
54
|
-
end
|
|
55
|
-
```
|
|
34
|
+
- WhatsApp Business API (text, buttons, images)
|
|
35
|
+
- Telegram Bot API (text, callbacks)
|
|
36
|
+
- HMAC-SHA256 webhook verification
|
|
37
|
+
- HTTP retry with exponential backoff for 429/5xx
|
|
38
|
+
- Input validation and error handling
|
|
39
|
+
- Logging hooks (on_request, on_response, on_error)
|
|
56
40
|
|
|
57
41
|
## License
|
|
58
42
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectorRuby
|
|
4
|
+
class BatchSender
|
|
5
|
+
RATE_LIMITS = {
|
|
6
|
+
whatsapp: { messages_per_second: 80 },
|
|
7
|
+
telegram: { messages_per_second: 30 },
|
|
8
|
+
messenger: { messages_per_second: 200 },
|
|
9
|
+
line: { messages_per_second: 100 },
|
|
10
|
+
slack: { messages_per_second: 1 }
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(channel:)
|
|
14
|
+
@channel = channel
|
|
15
|
+
@results = []
|
|
16
|
+
@errors = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def send_batch(messages)
|
|
20
|
+
rate = rate_limit_for(@channel)
|
|
21
|
+
delay = 1.0 / rate
|
|
22
|
+
|
|
23
|
+
messages.each_with_index do |msg, i|
|
|
24
|
+
sleep(delay) if i > 0
|
|
25
|
+
begin
|
|
26
|
+
result = yield msg
|
|
27
|
+
@results << { index: i, status: :sent, result: result }
|
|
28
|
+
rescue => e
|
|
29
|
+
@errors << { index: i, status: :failed, error: e.message }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
{ sent: @results, failed: @errors, total: messages.size }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def rate_limit_for(channel)
|
|
39
|
+
channel_sym = channel.class.name.split("::").last.downcase.to_sym
|
|
40
|
+
config = RATE_LIMITS[channel_sym] || { messages_per_second: 10 }
|
|
41
|
+
config[:messages_per_second]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectorRuby
|
|
4
|
+
module Channels
|
|
5
|
+
class Line < Base
|
|
6
|
+
BASE_URL = "https://api.line.me/v2/bot/message"
|
|
7
|
+
|
|
8
|
+
def initialize(channel_access_token: nil)
|
|
9
|
+
@channel_access_token = channel_access_token || ConnectorRuby.configuration.line_channel_access_token
|
|
10
|
+
raise ConfigurationError, "LINE channel_access_token is required" unless @channel_access_token
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def send_text(to:, text:)
|
|
14
|
+
validate_send!(to: to, text: text)
|
|
15
|
+
push_message(to, [{ type: "text", text: text }])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def send_buttons(to:, text:, buttons:)
|
|
19
|
+
validate_send!(to: to, text: text)
|
|
20
|
+
actions = buttons.map do |btn|
|
|
21
|
+
{ type: "postback", label: btn[:title], data: btn[:id] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
template = {
|
|
25
|
+
type: "template",
|
|
26
|
+
altText: text,
|
|
27
|
+
template: {
|
|
28
|
+
type: "buttons",
|
|
29
|
+
text: text,
|
|
30
|
+
actions: actions
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
push_message(to, [template])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def send_image(to:, url:, caption: nil)
|
|
37
|
+
validate_send!(to: to)
|
|
38
|
+
messages = [{ type: "image", originalContentUrl: url, previewImageUrl: url }]
|
|
39
|
+
messages << { type: "text", text: caption } if caption
|
|
40
|
+
push_message(to, messages)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def send_flex(to:, alt_text:, contents:)
|
|
44
|
+
validate_send!(to: to)
|
|
45
|
+
flex = {
|
|
46
|
+
type: "flex",
|
|
47
|
+
altText: alt_text,
|
|
48
|
+
contents: contents
|
|
49
|
+
}
|
|
50
|
+
push_message(to, [flex])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.parse_webhook(body)
|
|
54
|
+
data = body.is_a?(String) ? JSON.parse(body) : body
|
|
55
|
+
|
|
56
|
+
events = data["events"]
|
|
57
|
+
return nil unless events&.any?
|
|
58
|
+
|
|
59
|
+
event_data = events[0]
|
|
60
|
+
case event_data["type"]
|
|
61
|
+
when "message"
|
|
62
|
+
parse_message(event_data)
|
|
63
|
+
when "postback"
|
|
64
|
+
parse_postback(event_data)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def push_message(to, messages)
|
|
71
|
+
payload = { to: to, messages: messages }
|
|
72
|
+
http_client.post("#{BASE_URL}/push", body: payload, headers: auth_headers)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def auth_headers
|
|
76
|
+
{ "Authorization" => "Bearer #{@channel_access_token}" }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_send!(to:, text: nil)
|
|
80
|
+
raise ConnectorRuby::Error, "Recipient 'to' cannot be nil or empty" if to.nil? || to.to_s.strip.empty?
|
|
81
|
+
if text
|
|
82
|
+
raise ConnectorRuby::Error, "Text cannot be nil or empty" if text.nil? || text.to_s.strip.empty?
|
|
83
|
+
raise ConnectorRuby::Error, "Text exceeds 5000 character limit" if text.length > 5000
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.parse_message(event_data)
|
|
88
|
+
msg = event_data["message"]
|
|
89
|
+
Event.new(
|
|
90
|
+
type: :message,
|
|
91
|
+
channel: :line,
|
|
92
|
+
from: event_data.dig("source", "userId"),
|
|
93
|
+
text: msg["text"],
|
|
94
|
+
timestamp: event_data["timestamp"] ? Time.at(event_data["timestamp"].to_i / 1000) : nil,
|
|
95
|
+
message_id: msg["id"],
|
|
96
|
+
metadata: {
|
|
97
|
+
reply_token: event_data["replyToken"],
|
|
98
|
+
source_type: event_data.dig("source", "type"),
|
|
99
|
+
message_type: msg["type"]
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.parse_postback(event_data)
|
|
105
|
+
Event.new(
|
|
106
|
+
type: :callback,
|
|
107
|
+
channel: :line,
|
|
108
|
+
from: event_data.dig("source", "userId"),
|
|
109
|
+
text: event_data.dig("postback", "data"),
|
|
110
|
+
timestamp: event_data["timestamp"] ? Time.at(event_data["timestamp"].to_i / 1000) : nil,
|
|
111
|
+
metadata: {
|
|
112
|
+
reply_token: event_data["replyToken"],
|
|
113
|
+
source_type: event_data.dig("source", "type")
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -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,9 @@ 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,
|
|
7
10
|
:http_timeout, :http_retries, :http_open_timeout,
|
|
8
11
|
:on_request, :on_response, :on_error
|
|
9
12
|
|
|
@@ -11,6 +14,9 @@ module ConnectorRuby
|
|
|
11
14
|
@whatsapp_phone_number_id = nil
|
|
12
15
|
@whatsapp_access_token = nil
|
|
13
16
|
@telegram_bot_token = nil
|
|
17
|
+
@messenger_page_access_token = nil
|
|
18
|
+
@line_channel_access_token = nil
|
|
19
|
+
@slack_bot_token = nil
|
|
14
20
|
@http_timeout = 30
|
|
15
21
|
@http_retries = 3
|
|
16
22
|
@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
|
-
|
|
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
|
data/lib/connector_ruby.rb
CHANGED
|
@@ -10,6 +10,11 @@ require_relative "connector_ruby/webhook_verifier"
|
|
|
10
10
|
require_relative "connector_ruby/channels/base"
|
|
11
11
|
require_relative "connector_ruby/channels/whatsapp"
|
|
12
12
|
require_relative "connector_ruby/channels/telegram"
|
|
13
|
+
require_relative "connector_ruby/channels/messenger"
|
|
14
|
+
require_relative "connector_ruby/channels/line"
|
|
15
|
+
require_relative "connector_ruby/channels/slack"
|
|
16
|
+
require_relative "connector_ruby/batch_sender"
|
|
17
|
+
require_relative "connector_ruby/delivery_tracker"
|
|
13
18
|
|
|
14
19
|
module ConnectorRuby
|
|
15
20
|
class << self
|
|
@@ -29,4 +34,7 @@ module ConnectorRuby
|
|
|
29
34
|
# Convenience aliases
|
|
30
35
|
WhatsApp = Channels::WhatsApp
|
|
31
36
|
Telegram = Channels::Telegram
|
|
37
|
+
Messenger = Channels::Messenger
|
|
38
|
+
Line = Channels::Line
|
|
39
|
+
Slack = Channels::Slack
|
|
32
40
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: connector-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Johannes Dwi Cahyo
|
|
@@ -65,10 +65,15 @@ files:
|
|
|
65
65
|
- Rakefile
|
|
66
66
|
- connector-ruby.gemspec
|
|
67
67
|
- lib/connector_ruby.rb
|
|
68
|
+
- lib/connector_ruby/batch_sender.rb
|
|
68
69
|
- lib/connector_ruby/channels/base.rb
|
|
70
|
+
- lib/connector_ruby/channels/line.rb
|
|
71
|
+
- lib/connector_ruby/channels/messenger.rb
|
|
72
|
+
- lib/connector_ruby/channels/slack.rb
|
|
69
73
|
- lib/connector_ruby/channels/telegram.rb
|
|
70
74
|
- lib/connector_ruby/channels/whatsapp.rb
|
|
71
75
|
- lib/connector_ruby/configuration.rb
|
|
76
|
+
- lib/connector_ruby/delivery_tracker.rb
|
|
72
77
|
- lib/connector_ruby/error.rb
|
|
73
78
|
- lib/connector_ruby/event.rb
|
|
74
79
|
- lib/connector_ruby/http_client.rb
|