twi 0.2.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 016ae01fae07b7e70c6f405174016450c862159332b87cacd470478c317817ff
4
- data.tar.gz: 0ffcef985cc4256bf2ed9b8a7ad31ce98dd397786b825393d9aada604a1f93c8
3
+ metadata.gz: a3da0ed4a9e8746dd8609affeb9cf316a156dafa9a20b0da5e5307c37ea95ad0
4
+ data.tar.gz: 647d377b97c2fc7951ecd61f3caeb100b5d4ceeb8e1a8d788a5ba0b623a60ef9
5
5
  SHA512:
6
- metadata.gz: 146e6081e40bb06aa2a4c2c404614a8d316ef16e171191b26460121ad2e654092410927e16ba02c819c42f33c3d746d13b306ea0514dc01af5a1ec3c44319181
7
- data.tar.gz: 1e46956a86d74ff2077fdcc68b3698e2398b6534be2578dedad528bf6685209691067edf90e09b34f571280537590ff392a5d8b945901a737fe0143c1294f3f8
6
+ metadata.gz: 375a364348e16d823426c5670e22a900f52d50d5ee9ac2405f80cd6cd9e3cd7052e20d2d8511f50d44036f579ce273d8a2929908f470fd01aeeaf576e0ec6993
7
+ data.tar.gz: 97361223e531df80107ed916fa9f81774a9a8694a2e9df8b7acc5dd58468f02c10616df901e798d88f3be3e93b9f1cd4b4c8a3952525ab618378ea1d5a520063
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## [0.3.0] - 2026-05-27
2
+
3
+ - [New] Add Twi::Message#create
4
+ - [New] Add Twi::Conversation#upload
5
+ - [New] Add Twi.event, Twi.mock.medium
6
+ - [New] Add Twi.conversation, Twi.mock.conversation, Twi.mock.conversation_error
7
+ - [New] Add Twi::Event.params_for
8
+ - [New] Add Twi.create_phone, Twi.mock.phone, Twi.mock.phone_error
9
+ - [New] Add Twi::Phone
10
+
1
11
  ## [0.2.0] - 2026-05-25
2
12
 
3
13
  - [New] Twi::Delivery as a wrapper for direct message deliveries
data/README.md CHANGED
@@ -55,20 +55,42 @@ event.target # => :participant
55
55
  event.participant # => #<Participant id: 'SH12', phone: '9008009000', identity: nil>
56
56
  ```
57
57
 
58
+ ### Twi::Phone
59
+
60
+ To create an incoming phone number:
61
+
62
+ ```ruby
63
+ phone = Twi.create_phone area_code:, friendly_name:
64
+ phone.id # => 'SM083e290bef7794c407f14e65a891aa6d'
65
+ phone.number # => '5556667777'
66
+ ```
67
+
68
+ ## Available mocks
69
+
70
+ Use these methods to mock request to Twilio when testing an app:
71
+
72
+ ### Credentials
73
+
74
+ Mock an error when creating an incoming phone number:
75
+
76
+ ```ruby
77
+ Jbr.mock.phone_error = { code: '21452' }
78
+ ```
79
+
80
+ Mock successfully creating an incoming phone number:
81
+
82
+ ```ruby
83
+ Jbr.mock.phone = { id: 'SM083e290bef7794c407f14e65a891aa6d', number: '8009005000' }
84
+ ```
85
+
86
+
58
87
  ## To Do
59
88
 
60
- 1. have a Rails engine with the webhook URLs already set?
61
89
  4. have an interface to send and receive SMS with photos
62
- 5. another webhook for conversations
63
- 6. another one to be notified of deliveries
64
- 7. Assistant > create a phone number
65
90
  8. have an error code URL for each error code and a sid_url
66
91
  9. Declare some phones like Twilio.homeowner_phone or Twilio.numbers[:ddd] and a default Twilio.number and similar Twilio.messaging_service
67
92
  10. a way to reopen closed conversations
68
93
  11. a way to create conversations
69
94
  12. and upload pictures in a conversation
70
95
  13. x_twilio_webhook_enabled: true
71
- 14. return SIDs so we can store them
72
- 15. Set up defaults likw Twi::Lio.sid
73
- 16. Twi.mock = true
74
96
 
@@ -0,0 +1,82 @@
1
+ # Enhances the Twilio Ruby gem with an object-oriented approach.
2
+ module Twi
3
+ # The representation of a (classic) group conversation.
4
+ class Conversation < Resource
5
+ attr_reader :id, :status
6
+
7
+ def create_with(participants:)
8
+ params = create_params_for participants
9
+ conversation = conversation_service.conversation_with_participants.create **params
10
+
11
+ @id = conversation.sid
12
+ @status = conversation.state
13
+ rescue Twilio::REST::RestError => error
14
+ case error.code
15
+ when 50438 then raise ExistingConversationError.new(error.error_message)
16
+ when 50214 then raise TooManyConversationsError.new(error.error_message)
17
+ else raise
18
+ end
19
+ end
20
+
21
+ def rename(friendly_name)
22
+ conversation.update friendly_name: friendly_name
23
+ end
24
+
25
+ def close
26
+ conversation.update state: :closed
27
+ end
28
+
29
+ def delete
30
+ conversation.delete
31
+ end
32
+
33
+ def create_message(content:, image_ids: [])
34
+ conversation.messages.create **message_params_for(content, image_ids)
35
+ end
36
+
37
+ # TODO: Move into Medium -- this method doesn't use anything from this class
38
+ def upload(file)
39
+ uri = URI "https://mcs.us1.twilio.com/v1/Services/#{Twi.lio.conversation_sid}/Media"
40
+ http = Net::HTTP.new(uri.host, uri.port)
41
+ http.use_ssl = true
42
+
43
+ headers = { 'Content-Type' => file.content_type, 'Content-Size' => file.byte_size.to_s }
44
+ request = Net::HTTP::Post.new uri.request_uri, headers
45
+ request.basic_auth Twi.lio.api_key, Twi.lio.secret
46
+ request.body = file.download
47
+ response = http.request request
48
+ JSON(response.body)['sid']
49
+ end
50
+
51
+ private
52
+
53
+ def conversation
54
+ conversation_service.conversations @params[:id]
55
+ end
56
+
57
+ def create_params_for(participants)
58
+ {
59
+ messaging_service_sid: Twi.lio.messaging_sid, x_twilio_webhook_enabled: 'true',
60
+ friendly_name: @params[:friendly_name], participant: participant_params_for(participants),
61
+ }
62
+ end
63
+
64
+ def participant_params_for(participants)
65
+ participants.map do |participant|
66
+ phone = "+1#{participant[:phone]}"
67
+ if participant[:identity]
68
+ { messaging_binding: { projected_address: phone }, identity: participant[:identity] }
69
+ else
70
+ { messaging_binding: { address: phone } }
71
+ end
72
+ end.map(&:to_json)
73
+ end
74
+
75
+ def message_params_for(content, image_ids)
76
+ {
77
+ author: @params[:author], body: content,
78
+ media_sid: image_ids, x_twilio_webhook_enabled: 'true',
79
+ }.compact_blank
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,10 @@
1
+ # Enhances the Twilio Ruby gem with an object-oriented approach.
2
+ module Twi
3
+ # An event signaling a status change for a conversation.
4
+ class ConversationEvent < Event
5
+ # @return [Hash] the shape of the payload send by Twilio to the callback URL.
6
+ def self.params_for(id:, status:)
7
+ { ConversationSid: id, EventType: 'onConversationChanged', State: status.to_s }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ # Enhances the Twilio Ruby gem with an object-oriented approach.
2
+ module Twi
3
+ # An event signaling the delivery of a message in a conversation.
4
+ class DeliveryEvent < Event
5
+ # @return [Hash] the shape of the payload send by Twilio to the callback URL.
6
+ def self.params_for(id:, participant_id:, message_id:, status:, code: nil)
7
+ {
8
+ ConversationSid: id, EventType: 'onDeliveryChanged', ParticipantSid: participant_id.to_s,
9
+ MessageSid: message_id, Status: status.to_s, ErrorCode: code
10
+ }.compact
11
+ end
12
+ end
13
+ end
data/lib/twi/error.rb ADDED
@@ -0,0 +1,11 @@
1
+ module Twi
2
+ class Error < StandardError
3
+ end
4
+
5
+ class ExistingConversationError < Error
6
+ def conversation_id = message[/Conversation (.+)/, 1]
7
+ end
8
+
9
+ class TooManyConversationsError < Error
10
+ end
11
+ end
data/lib/twi/event.rb CHANGED
@@ -1,16 +1,9 @@
1
1
  # Enhances the Twilio Ruby gem with an object-oriented approach.
2
2
  module Twi
3
- # The representation of an event tied to a (clasic) conversation.
3
+ # An event tied to a (classic) conversation.
4
4
  class Event < Resource
5
- # @return [Symbol] what the event is about
6
- def target
7
- case @params['EventType']
8
- when 'onConversationAdded', 'onConversationStateUpdated' then :conversation
9
- when 'onParticipantAdded' then :participant
10
- when 'onMessageAdded' then :message
11
- when 'onDeliveryUpdated' then :delivery
12
- end
13
- end
5
+ # @return [Symbol] event target type, can be :conversation, :participant, :message, :delivery.
6
+ def target = @params['EventType'].underscore.split('_').second.to_sym
14
7
 
15
8
  # @return [String] conversation state, one of active, inactive, closed, initializing.
16
9
  def status = @params['Status'] || @params['StateTo'] || @params['State']
@@ -26,7 +19,7 @@ module Twi
26
19
 
27
20
  # @return [Array<String>] URLs of image attachments
28
21
  def image_urls
29
- media = JSON(@params.fetch('Media', '[]')).map { |params| Medium.new params }
22
+ media = JSON(@params.fetch('Media', '[]')).map { |params| medium_for params }
30
23
  media.filter(&:image?).map { |image| image.url }
31
24
  end
32
25
 
@@ -35,5 +28,29 @@ module Twi
35
28
 
36
29
  # @return [String, nil] error code
37
30
  def code = @params['ErrorCode']
31
+
32
+ # @return [Hash] the shape of the payload send by Twilio to the callback URL.
33
+ def self.params_for(id:, type:, status: nil, participant: nil)
34
+ {
35
+ ConversationSid: id, EventType: "on_#{type}_changed".camelize(:lower),
36
+ State: status&.to_s,
37
+ }.merge(participant_params_for participant).compact
38
+ end
39
+
40
+ def self.participant_params_for(participant = nil)
41
+ if participant
42
+ if participant[:identity]
43
+ { ParticipantSid: participant[:id], Identity: participant[:identity] }
44
+ else
45
+ { ParticipantSid: participant[:id], 'MessagingBinding.Address' => "+1#{participant[:phone]}" }
46
+ end
47
+ else
48
+ {}
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def medium_for(params) = Medium.new params
38
55
  end
39
56
  end
data/lib/twi/lio.rb CHANGED
@@ -30,18 +30,34 @@ module Twi
30
30
  class Lio
31
31
  # Initialize the global configuration settings defaulting to matching environment variables.
32
32
  def initialize
33
- @sid = ENV['TWILIO_SID']
33
+ @api_key = ENV['TWILIO_SID']
34
34
  @secret = ENV['TWILIO_SECRET']
35
- @conversation_service_sid = ENV['TWILIO_CONVERSATION_SERVICE_SID']
35
+ @account_sid = ENV['TWILIO_ACCOUNT_SID']
36
+ @auth_token = ENV['TWILIO_AUTH_TOKEN']
37
+ @conversation_sid = ENV['TWILIO_CONVERSATION_SERVICE_SID']
38
+ @messaging_sid = ENV['TWILIO_MESSAGING_SID']
39
+ @emergency_address_sid = ENV['TWILIO_EMERGENCY_SID']
36
40
  end
37
41
 
38
- # @return [String] the SID to interact with the Twilio API.
39
- attr_reader :sid
42
+ # @return [String] API key - used for basic operations like sending messages.
43
+ attr_reader :api_key
40
44
 
41
- # @return [String] the secret to interact with the Twilio API.
45
+ # @return [String] secret - to authenticate API requests made with +api_key+.
42
46
  attr_reader :secret
43
47
 
44
- # @return [String] the SID of the default Conversation service to use.
45
- attr_reader :conversation_service_sid
48
+ # @return [String] Account SID - used for advanced operations like creating numbers.
49
+ attr_reader :account_sid
50
+
51
+ # @return [String] OAuth token - to authenticate API requests made with +account_sid+.
52
+ attr_reader :auth_token
53
+
54
+ # @return [String] the SID of the default Conversation service.
55
+ attr_reader :conversation_sid
56
+
57
+ # @return [String] the SID of the default Messaging service.
58
+ attr_reader :messaging_sid
59
+
60
+ # @return [String] the SID of the emergency address.
61
+ attr_reader :emergency_address_sid
46
62
  end
47
63
  end
data/lib/twi/medium.rb CHANGED
@@ -18,7 +18,7 @@ module Twi
18
18
  http.use_ssl = true
19
19
 
20
20
  request = Net::HTTP::Get.new(uri.request_uri)
21
- request.basic_auth Twi.lio.sid, Twi.lio.secret
21
+ request.basic_auth Twi.lio.api_key, Twi.lio.secret
22
22
  response = http.request(request)
23
23
 
24
24
  JSON(response.body).dig 'links', 'content_direct_temporary'
@@ -27,7 +27,7 @@ module Twi
27
27
  private
28
28
 
29
29
  def service_url
30
- "https://mcs.us1.twilio.com/v1/Services/#{Twi.lio.conversation_service_sid}/Media/#{id}"
30
+ "https://mcs.us1.twilio.com/v1/Services/#{Twi.lio.conversation_sid}/Media/#{id}"
31
31
  end
32
32
  end
33
33
  end
data/lib/twi/message.rb CHANGED
@@ -30,6 +30,18 @@ module Twi
30
30
  end
31
31
  end
32
32
 
33
+ attr_reader :status
34
+
35
+ # Sends a message.
36
+ def create
37
+ message = api_client.messages.create messaging_service_sid: Twi.lio.messaging_sid.to_s,
38
+ from: "+1#{@params[:sender]}", to: "+1#{@params[:recipient]}", body: @params[:content]
39
+
40
+ @id = message.sid
41
+ @status = message.status
42
+ # todo rescue and then set @code
43
+ end
44
+
33
45
  # @return [Hash] the shape of the payload send by Twilio to the callback URL.
34
46
  def self.params_for(id:, sender:, recipient:, wallflower: nil, content: nil, opt: nil, media: [])
35
47
  {
@@ -0,0 +1,21 @@
1
+ # Enhances the Twilio Ruby gem with an object-oriented approach.
2
+ module Twi
3
+ # An event signaling a new message in a conversation.
4
+ class MessageEvent < Event
5
+ # @return [Hash] the shape of the payload send by Twilio to the callback URL.
6
+ def self.params_for(id:, participant_id:, content: nil, media: [])
7
+ {
8
+ ConversationSid: id, EventType: 'onMessageChanged', ParticipantSid: participant_id.to_s,
9
+ MessageSid: "SM#{rand}", Body: content, Media: media_params_for(media),
10
+ }.compact_blank
11
+ end
12
+
13
+ private
14
+
15
+ def self.media_params_for(media)
16
+ media.map do |medium|
17
+ { Sid: medium[:id], ContentType: medium[:content_type] }
18
+ end.to_json if media.present?
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ module Twi
2
+ class Mock::Conversation < Conversation
3
+ def create_with(participants:)
4
+ if Twi.mock.conversation_error
5
+ if Twi.mock.conversation_error[:code] == 50438
6
+ raise ExistingConversationError.new(Twi.mock.conversation_error[:message])
7
+ elsif Twi.mock.conversation_error[:code] == 50214
8
+ Twi.mock.conversation_error = nil
9
+ raise TooManyConversationsError
10
+ end
11
+ elsif Twi.mock.conversation
12
+ @id = Twi.mock.conversation[:id]
13
+ @status = Twi.mock.conversation[:status]
14
+ end
15
+ end
16
+
17
+ def upload(file) = 'fake-sid'
18
+
19
+ def rename(friendly_name); end
20
+
21
+ def close; end
22
+
23
+ def delete; end
24
+
25
+ def create_message(content:, image_ids: [])
26
+ Mock::Message.new Twi.mock.message
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,6 @@
1
+ module Twi
2
+ class Mock::Event < Event
3
+ private
4
+ def medium_for(params) = Mock::Medium.new params
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ module Twi
2
+ class Mock::Medium < Medium
3
+ # @return [String] a mock URL for the image.
4
+ def url
5
+ Twi.mock.medium[:url] if Twi.mock.medium
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,12 @@
1
+ module Twi
2
+ class Mock::Message < Message
3
+ # @return [String] unique identifier
4
+ def id = @params[:id]
5
+
6
+ def create
7
+ @id = "SM#{rand}"
8
+ @status = 'delivered'
9
+ # todo rescue and then set @code
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ module Twi
2
+ class Mock::Phone < Phone
3
+ def create
4
+ if Twi.mock.phone
5
+ @id = Twi.mock.phone[:id]
6
+ @number = Twi.mock.phone[:number]
7
+ elsif Twi.mock.phone_error
8
+ raise Error, Twi.mock.phone_error
9
+ end
10
+ end
11
+ end
12
+ end
data/lib/twi/mock.rb ADDED
@@ -0,0 +1,5 @@
1
+ module Twi
2
+ class Mock
3
+ attr_accessor :phone, :phone_error, :message, :conversation, :conversation_error, :medium
4
+ end
5
+ end
@@ -0,0 +1,33 @@
1
+ module Twi
2
+ module Mocking
3
+ def mock
4
+ @mock ||= Twi::Mock.new
5
+ end
6
+
7
+ def create_phone(...)
8
+ phone(...).tap &:create
9
+ end
10
+
11
+ def create_message(...)
12
+ message(...).tap &:create
13
+ end
14
+
15
+ def conversation(...)
16
+ (@mock ? Mock::Conversation : Conversation).new(...)
17
+ end
18
+
19
+ def event(...)
20
+ (@mock ? Mock::Event : Event).new(...)
21
+ end
22
+
23
+ def phone(...)
24
+ (@mock ? Mock::Phone : Phone).new(...)
25
+ end
26
+
27
+ def message(...)
28
+ (@mock ? Mock::Message : Message).new(...)
29
+ end
30
+ end
31
+
32
+ extend Mocking
33
+ end
@@ -0,0 +1,19 @@
1
+ # Enhances the Twilio Ruby gem with an object-oriented approach.
2
+ module Twi
3
+ # An event signaling a participant added to a conversation.
4
+ class ParticipantEvent < Event
5
+ # @return [Hash] the shape of the payload send by Twilio to the callback URL.
6
+ def self.params_for(id:, participant_id:, identity: nil, phone: nil)
7
+ {
8
+ ConversationSid: id, EventType: 'onParticipantChanged',
9
+ ParticipantSid: participant_id.to_s
10
+ }.merge participant_params_for(identity, phone)
11
+ end
12
+
13
+ private
14
+
15
+ def self.participant_params_for(identity, phone)
16
+ identity ? { Identity: identity } : { 'MessagingBinding.Address' => "+1#{phone}" }
17
+ end
18
+ end
19
+ end
data/lib/twi/phone.rb ADDED
@@ -0,0 +1,21 @@
1
+ # Enhances the Twilio Ruby gem with an object-oriented approach.
2
+ module Twi
3
+ # The representation of a phone number associated to the default messaging service.
4
+ class Phone < Resource
5
+ # Create an incoming phone number within the area code and friendly name.
6
+ def create
7
+ phone = client.incoming_phone_numbers.create area_code: @params[:area_code],
8
+ emergency_address_sid: Twi.lio.emergency_address_sid, friendly_name: @params[:friendly_name]
9
+ messaging_service.phone_numbers.create phone_number_sid: phone.sid
10
+
11
+ @id = phone.sid
12
+ @number = remove_prefix_from phone.phone_number
13
+ end
14
+
15
+ # @return [String] unique identifier of the phone.
16
+ attr_reader :id
17
+
18
+ # @return [String] 10-digit number.
19
+ attr_reader :number
20
+ end
21
+ end
data/lib/twi/resource.rb CHANGED
@@ -10,5 +10,13 @@ module Twi
10
10
  private
11
11
 
12
12
  def remove_prefix_from(number) = number&.strip&.delete_prefix '+1'
13
+
14
+ def client = Twilio::REST::Client.new Twi.lio.account_sid, Twi.lio.auth_token
15
+
16
+ def api_client = Twilio::REST::Client.new Twi.lio.api_key, Twi.lio.secret
17
+
18
+ def messaging_service = client.messaging.v1.services Twi.lio.messaging_sid.to_s
19
+
20
+ def conversation_service = client.conversations.v1.services Twi.lio.conversation_sid.to_s
13
21
  end
14
22
  end
data/lib/twi/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Twi
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/twi.rb CHANGED
@@ -2,11 +2,14 @@
2
2
 
3
3
  require 'action_controller'
4
4
  require 'action_controller/metal/strong_parameters'
5
+ require 'active_support/core_ext/enumerable'
6
+ require 'active_support/core_ext/object/blank'
5
7
  require 'json'
6
8
  require 'net/http'
7
9
  require 'twilio-ruby'
8
10
 
9
11
  require 'twi/lio'
12
+ require 'twi/error'
10
13
  require 'twi/config'
11
14
  require 'twi/resource'
12
15
  require 'twi/message'
@@ -14,3 +17,17 @@ require 'twi/delivery'
14
17
  require 'twi/participant'
15
18
  require 'twi/medium'
16
19
  require 'twi/event'
20
+ require 'twi/conversation_event'
21
+ require 'twi/delivery_event'
22
+ require 'twi/message_event'
23
+ require 'twi/participant_event'
24
+ require 'twi/phone'
25
+ require 'twi/conversation'
26
+
27
+ require 'twi/mock'
28
+ require 'twi/mock/phone'
29
+ require 'twi/mock/conversation'
30
+ require 'twi/mock/event'
31
+ require 'twi/mock/medium'
32
+ require 'twi/mock/message'
33
+ require 'twi/mocking'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: twi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -49,12 +49,26 @@ files:
49
49
  - README.md
50
50
  - lib/twi.rb
51
51
  - lib/twi/config.rb
52
+ - lib/twi/conversation.rb
53
+ - lib/twi/conversation_event.rb
52
54
  - lib/twi/delivery.rb
55
+ - lib/twi/delivery_event.rb
56
+ - lib/twi/error.rb
53
57
  - lib/twi/event.rb
54
58
  - lib/twi/lio.rb
55
59
  - lib/twi/medium.rb
56
60
  - lib/twi/message.rb
61
+ - lib/twi/message_event.rb
62
+ - lib/twi/mock.rb
63
+ - lib/twi/mock/conversation.rb
64
+ - lib/twi/mock/event.rb
65
+ - lib/twi/mock/medium.rb
66
+ - lib/twi/mock/message.rb
67
+ - lib/twi/mock/phone.rb
68
+ - lib/twi/mocking.rb
57
69
  - lib/twi/participant.rb
70
+ - lib/twi/participant_event.rb
71
+ - lib/twi/phone.rb
58
72
  - lib/twi/resource.rb
59
73
  - lib/twi/version.rb
60
74
  homepage: https://github.com/claudiob/twi