sip2 0.1.1 → 0.2.4

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: e8f73fce390c230b1c0d95bba4221ebe77368b49a394635d84d8bd8314208ebb
4
- data.tar.gz: c1b9beff001b1e3774a57853d2b031c5154fe51b793be2c301968744e61ca08d
3
+ metadata.gz: 7ef89c74848c5fbf09675afeb914f52985512f780fdcdc086288cebf6cd245c1
4
+ data.tar.gz: db0228463ed3b7466c650d4f3882befa65deeebbdd7433692698321dcada9a4b
5
5
  SHA512:
6
- metadata.gz: 44d2323ee3939e99bdb192c5f61ce0be327321e99f5260592f62260ad3145d2681353ef759aff7a3193c09c7b5403e1f08f50fac661ecf22a063ec3ba475cfa8
7
- data.tar.gz: 22e1645b0b13fcdff5de58757a9ba052191173c7fd374572fdadfaeba99a66b4b495d3dbb2b98dc717692f2f3c0442c975769c6917aaf7097d4c6f9a600ffeee
6
+ metadata.gz: 3b57c2fc70a1514c5bd29741b5ec07f08f539105231ba0849f27e62d960e12d667a9e1dc22a089ddd250a85fef82497fc1ba0c3821060c651179bd8880d9ae9d
7
+ data.tar.gz: 056c703e7f951b0aeb92b41322d050627f3c354162d6dc8af985a3ae1ca649e100be6620d3cbe68e36090a8c7c64b00a98ff647394c5260918b2a01f7da9bc1c
data/README.md CHANGED
@@ -42,6 +42,45 @@ patron =
42
42
  puts 'Valid patron' if patron && patron.authenticated?
43
43
  ```
44
44
 
45
+ ### Using TLS
46
+
47
+ The Sip2::Client will use TLS if a
48
+ [SSLContext](https://ruby-doc.org/stdlib-2.4.10/libdoc/openssl/rdoc/OpenSSL/SSL/SSLContext.html)
49
+ is passed in the `ssl_context` parameter. There are quite a few ways this can be configured, but that will
50
+ depend on how the server being connected to is configured. A basic example is:
51
+
52
+ ```ruby
53
+ # Setup a cert store using the system certificates/trust chain
54
+ cert_store = OpenSSL::X509::Store.new
55
+ cert_store.set_default_paths
56
+
57
+ # Setup the SSL context
58
+ ssl_context = OpenSSL::SSL::SSLContext.new
59
+ ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER # This is important. We want to verify the certificate is legitimate
60
+ ssl_context.min_version = OpenSSL::SSL::TLS1_2_VERSION # Generally good practice to enforce the most recent TLS version
61
+ ssl_context.cert_store = cert_store # Use the certificate store we configured above
62
+
63
+ # Raise an exception if the certificate doesn't check out
64
+ ssl_context.verify_callback = proc do |preverify_ok, context|
65
+ raise OpenSSL::SSL::SSLError, <<~ERROR.strip if preverify_ok != true || context.error != 0
66
+ SSL Verification failed -- Preverify: #{preverify_ok}, Error: #{context.error_string} (#{context.error})
67
+ ERROR
68
+
69
+ true
70
+ end
71
+
72
+ client = Sip2::Client.new(host: 'my.sip2.host.net', port: 6001, ssl_context: ssl_context)
73
+ ```
74
+
75
+ If you needed to explicitly specify the certificate to be used there are a few options available
76
+ to use instead of cert_store (see the documentation for full details and other options):
77
+ * ca_file - path to a file containing a CA certificate
78
+ * ca_path - path to a directory containing CA certificates
79
+ * client_ca - a certificate or array of certificates
80
+ * client_cert_cb - callback where an array containing an X509 certificate and key are returned
81
+
82
+ Be sure to validate that your setup behaves the way you expect it to.
83
+ Pass in an invalid certificate and see it fails. Pass a mismatching hostname and see it fails. etc.
45
84
 
46
85
  ## Contributing
47
86
 
data/lib/sip2/client.rb CHANGED
@@ -1,20 +1,35 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Sip2
2
4
  #
3
5
  # Sip2 Client
4
6
  #
5
7
  class Client
6
- def initialize(host:, port:, ignore_error_detection: false, timeout: nil)
8
+ def initialize(host:, port:, ignore_error_detection: false, timeout: nil, ssl_context: nil)
7
9
  @host = host
8
10
  @port = port
9
11
  @ignore_error_detection = ignore_error_detection
10
12
  @timeout = timeout || NonBlockingSocket::DEFAULT_TIMEOUT
13
+ @ssl_context = ssl_context
11
14
  end
12
15
 
13
- def connect
14
- socket = NonBlockingSocket.connect @host, @port, @timeout
15
- yield Connection.new(socket, @ignore_error_detection) if block_given?
16
+ def connect # rubocop:disable Metrics/MethodLength
17
+ socket = NonBlockingSocket.connect host: @host, port: @port, timeout: @timeout
18
+
19
+ # If we've been provided with an SSL context then use it to wrap out existing connection
20
+ if @ssl_context
21
+ socket = ::OpenSSL::SSL::SSLSocket.new socket, @ssl_context
22
+ socket.hostname = @host # Needed for SNI
23
+ socket.sync_close = true
24
+ socket.connect
25
+ socket.post_connection_check @host # Validate the peer certificate matches the host
26
+ end
27
+
28
+ if block_given?
29
+ yield Connection.new(socket: socket, ignore_error_detection: @ignore_error_detection)
30
+ end
16
31
  ensure
17
- socket.close if socket
32
+ socket&.close
18
33
  end
19
34
  end
20
35
  end
@@ -1,57 +1,66 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Sip2
2
4
  #
3
5
  # Sip2 Connection
4
6
  #
5
7
  class Connection
6
- @connection_modules = []
7
-
8
- class << self
9
- attr_reader :connection_modules
10
-
11
- def add_connection_module(module_name)
12
- @connection_modules << module_name
13
- end
14
- end
15
-
16
- include Messages::Login
17
- include Messages::PatronInformation
8
+ LINE_SEPARATOR = "\r"
18
9
 
19
- def initialize(socket, ignore_error_detection)
10
+ def initialize(socket:, ignore_error_detection: false)
20
11
  @socket = socket
21
12
  @ignore_error_detection = ignore_error_detection
22
13
  @sequence = 1
23
14
  end
24
15
 
25
- def method_missing(method_name, *args)
26
- if Connection.connection_modules.include?(method_name.to_sym)
27
- send_and_handle_message(method_name, *args)
28
- else
16
+ def send_message(message)
17
+ message = with_checksum with_error_detection message
18
+ write_with_timeout message
19
+ # Read the response and strip any leading newline
20
+ # - Some ACS terminate messages with /r/n by mistake.
21
+ # We need to remove from the front (i.e. buffer remnant from the previous message)
22
+ response = read_with_timeout&.[](/\A\n?(.*)\z/, 1)
23
+ response if sequence_and_checksum_valid? response
24
+ ensure
25
+ @sequence += 1
26
+ end
27
+
28
+ def method_missing(method_name, *args, **kwargs)
29
+ message_class = Messages::Base.message_class_for_method(method_name)
30
+ if message_class.nil?
29
31
  super
32
+ else
33
+ message_class.new(self).action_message(*args, **kwargs)
30
34
  end
31
35
  end
32
36
 
33
37
  def respond_to_missing?(method_name, _include_private = false)
34
- Connection.connection_modules.include?(method_name.to_sym) || super
38
+ !Messages::Base.message_class_for_method(method_name).nil? || super
35
39
  end
36
40
 
37
41
  private
38
42
 
39
- def send_and_handle_message(message_type, *args)
40
- message = send "build_#{message_type}_message", *args
41
- message = with_checksum with_error_detection message
42
- response = send_message message
43
- return if response.nil?
43
+ def write_with_timeout(message, separator: LINE_SEPARATOR)
44
+ ::Timeout.timeout connection_timeout, WriteTimeout do
45
+ @socket.write message + separator
46
+ end
47
+ end
44
48
 
45
- send "handle_#{message_type}_response", response
49
+ def read_with_timeout(separator: LINE_SEPARATOR)
50
+ ::Timeout.timeout connection_timeout, ReadTimeout do
51
+ @socket.gets(separator)&.chomp(separator)
52
+ end
46
53
  end
47
54
 
48
- def send_message(message)
49
- @socket.send_with_timeout message
50
- @socket.gets_with_timeout
55
+ def connection_timeout
56
+ # We want the underlying connection where the timeout is configured,
57
+ # so if we're dealing with an SSLSocket then we need to unwrap it
58
+ io = @socket.respond_to?(:io) ? @socket.io : @socket
59
+ io.connection_timeout || NonBlockingSocket::DEFAULT_TIMEOUT
51
60
  end
52
61
 
53
62
  def with_error_detection(message)
54
- message + '|AY' + @sequence.to_s
63
+ "#{message}|AY#{@sequence}"
55
64
  end
56
65
 
57
66
  def with_checksum(message)
@@ -64,19 +73,18 @@ module Sip2
64
73
  message.each_char { |m| check += m.ord }
65
74
  check += "\0".ord
66
75
  check = (check ^ 0xFFFF) + 1
67
- format '%4.4X', check
76
+ format '%<check>4.4X', check: check
68
77
  end
69
78
 
70
79
  def sequence_and_checksum_valid?(response)
71
80
  return true if @ignore_error_detection
81
+ return false unless response.is_a? String
72
82
 
73
- sequence_regex = /^(?<message>.*?AY(?<sequence>[0-9]+)AZ)(?<checksum>[A-F0-9]{4})$/
83
+ sequence_regex = /\A(?<message>.*?AY(?<sequence>[0-9]+)AZ)(?<checksum>[A-F0-9]{4})\z/
74
84
  match = response.strip.match sequence_regex
75
85
  match &&
76
86
  match[:sequence] == @sequence.to_s &&
77
87
  match[:checksum] == checksum_for(match[:message])
78
- ensure
79
- @sequence += 1
80
88
  end
81
89
  end
82
90
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sip2
4
+ module Messages
5
+ #
6
+ # Sip2 Base message handler
7
+ #
8
+ class Base
9
+ @message_class_lookup = {}
10
+
11
+ # Class helper to fetch the descendant "message" class based on a method name
12
+ #
13
+ # @param [String] method_name the underscore case name of the class to fetch
14
+ # @return [Class] the message class fetched based on the method_name,
15
+ # `nil` if no descendant was found
16
+ # @example
17
+ # message_class_for_method('patron_information')
18
+ # => Sip2::Messages::PatronInformation
19
+ def self.message_class_for_method(method_name) # rubocop:disable Metrics/MethodLength
20
+ return @message_class_lookup[method_name] if @message_class_lookup.key? :method_name
21
+
22
+ @message_class_lookup[method_name] =
23
+ begin
24
+ # classify the method name so we can fetch the message class of the same name
25
+ class_name = method_name.to_s.capitalize.gsub(%r{(?:_|(/))([a-z\d]*)}i) do
26
+ "#{Regexp.last_match(1)}#{Regexp.last_match(2).capitalize}"
27
+ end
28
+ message_class = Messages.const_get(class_name)
29
+ message_class if message_class && message_class < self
30
+ rescue NameError
31
+ nil
32
+ end
33
+ end
34
+
35
+ def initialize(connection)
36
+ @connection = connection
37
+ end
38
+
39
+ # Action the message, passing the dynamic arguments to the specific message implementation
40
+ #
41
+ # @param [*various] args Arguments to pass to the specific message implementation
42
+ # @return returns `nil` if there was no valid message returned.
43
+ # Otherwise value will depend on the specific message. See the `handle_response`
44
+ # method in those classes for more information
45
+ def action_message(**args)
46
+ message = build_message(**args)
47
+ response = @connection.send_message message
48
+ return if response.nil?
49
+
50
+ handle_response(response)
51
+ end
52
+
53
+ private
54
+
55
+ def build_message(**)
56
+ raise NotImplementedError, "#{self.class} must implement `build_message` method"
57
+ end
58
+
59
+ def handle_response(_response)
60
+ raise NotImplementedError, "#{self.class} must implement `handle_response` method"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -1,20 +1,32 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Sip2
2
4
  module Messages
3
5
  #
4
- # Sip2 Login message module
6
+ # Sip2 Login message
5
7
  #
6
- module Login
7
- def self.included(klass)
8
- klass.add_connection_module :login
9
- end
10
-
8
+ # https://developers.exlibrisgroup.com/wp-content/uploads/2020/01/3M-Standard-Interchange-Protocol-Version-2.00.pdf
9
+ #
10
+ # Request message 93
11
+ # * UID algorithm - 1 char, fixed-length required field; the algorithm used
12
+ # to encrypt the user id
13
+ # * PWD algorithm - 1 char, fixed-length required field; the algorithm used
14
+ # to encrypt the password
15
+ # * login user id - CN - variable-length required field
16
+ # * login password - CO - variable-length required field
17
+ # * location code - CP - variable-length required field
18
+ #
19
+ # Response message 94
20
+ # * ok - 1 char, fixed-length required field: 0 or 1
21
+ #
22
+ class Login < Base
11
23
  private
12
24
 
13
- def build_login_message(username, password, location_code = nil)
25
+ def build_message(username:, password:, location_code: nil)
14
26
  code = '93' # Login
15
27
  uid_algorithm = pw_algorithm = '0' # Plain text
16
- username_field = 'CN' + username
17
- password_field = 'CO' + password
28
+ username_field = "CN#{username}"
29
+ password_field = "CO#{password}"
18
30
  location_code = location_code.strip if location_code.is_a? String
19
31
  location_field = location_code ? "|CP#{location_code}" : ''
20
32
 
@@ -23,8 +35,8 @@ module Sip2
23
35
  ].join
24
36
  end
25
37
 
26
- def handle_login_response(response)
27
- sequence_and_checksum_valid?(response) && response[/^94([01])AY/, 1] == '1'
38
+ def handle_response(response)
39
+ response[/\A94([01])AY/, 1] == '1'
28
40
  end
29
41
  end
30
42
  end
@@ -1,16 +1,27 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Sip2
2
4
  module Messages
3
5
  #
4
- # Sip2 Patron information message module
6
+ # Sip2 Patron information message
5
7
  #
6
- module PatronInformation
7
- def self.included(klass)
8
- klass.add_connection_module :patron_information
9
- end
10
-
8
+ # https://developers.exlibrisgroup.com/wp-content/uploads/2020/01/3M-Standard-Interchange-Protocol-Version-2.00.pdf
9
+ #
10
+ # Request message 63
11
+ # * language - 3 char, fixed-length required field
12
+ # * transaction date - 18 char, fixed-length required field: YYYYMMDDZZZZHHMMSS
13
+ # * summary - 10 char, fixed-length required field
14
+ # * institution id - AO - variable-length required field
15
+ # * patron identifier - AA - variable-length required field
16
+ # * terminal password - AC - variable-length optional field
17
+ # * patron password - AD - variable-length optional field
18
+ # * start item - BP - variable-length optional field
19
+ # * end item - BQ - variable-length optional field
20
+ #
21
+ class PatronInformation < Base
11
22
  private
12
23
 
13
- def build_patron_information_message(uid, password, terminal_password = nil)
24
+ def build_message(uid:, password:, terminal_password: nil)
14
25
  code = '63' # Patron information
15
26
  language = '000' # Unknown
16
27
  timestamp = Time.now.strftime('%Y%m%d %H%M%S')
@@ -21,10 +32,10 @@ module Sip2
21
32
  ].join
22
33
  end
23
34
 
24
- def handle_patron_information_response(response)
25
- return unless sequence_and_checksum_valid?(response)
35
+ def handle_response(response)
36
+ return unless /\A#{Sip2::Responses::PatronInformation::RESPONSE_ID}/o.match?(response)
26
37
 
27
- Sip2::PatronInformation.new response
38
+ Sip2::Responses::PatronInformation.new response
28
39
  end
29
40
  end
30
41
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sip2
4
+ module Messages
5
+ #
6
+ # Sip2 Patron information message
7
+ #
8
+ # https://developers.exlibrisgroup.com/wp-content/uploads/2020/01/3M-Standard-Interchange-Protocol-Version-2.00.pdf
9
+ #
10
+ # Request message 99
11
+ # * status code - 1 char, fixed-length required field: 0, 1 or 2
12
+ # * max print width - 3 char, fixed-length required field
13
+ # * protocol version - 4 char, fixed-length required field: x.xx
14
+ #
15
+ class Status < Base
16
+ STATUS_CODE_LOOKUP = {
17
+ ok: 0,
18
+ out_of_paper: 1,
19
+ about_to_shut_down: 2
20
+ }.freeze
21
+
22
+ private
23
+
24
+ def build_message(status_code: :ok, max_print_width: 999, protocol_version: 2)
25
+ [
26
+ '99', # SC Status
27
+ normalize_status_code(status_code),
28
+ normalize_print_width(max_print_width),
29
+ normalize_protocol_version(protocol_version)
30
+ ].join
31
+ end
32
+
33
+ def normalize_status_code(code)
34
+ format(
35
+ '%<code>d',
36
+ code: (code.is_a?(Symbol) ? STATUS_CODE_LOOKUP[code] : code).to_i.abs
37
+ )[0]
38
+ end
39
+
40
+ def normalize_print_width(width)
41
+ format('%03<width>d', width: width % 1000)
42
+ end
43
+
44
+ def normalize_protocol_version(version)
45
+ format('%.2<version>f', version: version % 10)
46
+ end
47
+
48
+ def handle_response(response)
49
+ return unless /\A#{Sip2::Responses::Status::RESPONSE_ID}/o.match?(response)
50
+
51
+ Sip2::Responses::Status.new response
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'socket'
2
4
  require 'timeout'
3
5
 
@@ -8,24 +10,11 @@ module Sip2
8
10
  #
9
11
  class NonBlockingSocket < Socket
10
12
  DEFAULT_TIMEOUT = 5
11
- SEPARATOR = "\r".freeze
12
13
 
13
14
  attr_accessor :connection_timeout
14
15
 
15
- def send_with_timeout(message, separator = SEPARATOR)
16
- ::Timeout.timeout (connection_timeout || DEFAULT_TIMEOUT), WriteTimeout do
17
- send message + separator, 0
18
- end
19
- end
20
-
21
- def gets_with_timeout(separator = SEPARATOR)
22
- ::Timeout.timeout (connection_timeout || DEFAULT_TIMEOUT), ReadTimeout do
23
- gets separator
24
- end
25
- end
26
-
27
16
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
28
- def self.connect(host, port, timeout = DEFAULT_TIMEOUT)
17
+ def self.connect(host:, port:, timeout: DEFAULT_TIMEOUT)
29
18
  # Convert the passed host into structures the non-blocking calls can deal with
30
19
  addr = Socket.getaddrinfo(host, nil)
31
20
  sockaddr = Socket.pack_sockaddr_in(port, addr[0][3])
@@ -41,13 +30,13 @@ module Sip2
41
30
  # indicating the connection is in progress.
42
31
  socket.connect_nonblock(sockaddr)
43
32
  rescue IO::WaitWritable
44
- # IO.select will block until the socket is writable or the timeout
45
- # is exceeded - whichever comes first.
46
- if IO.select(nil, [socket], nil, timeout)
33
+ # wait_writable waits until the socket is writable without blocking,
34
+ # and returns self or `nil` when times out
35
+ if socket.wait_writable(timeout)
47
36
  begin
48
37
  # Verify there is now a good connection
49
38
  socket.connect_nonblock(sockaddr)
50
- rescue Errno::EISCONN # rubocop:disable Lint/HandleExceptions
39
+ rescue Errno::EISCONN
51
40
  # Good news everybody, the socket is connected!
52
41
  rescue StandardError
53
42
  # An unexpected exception was raised - the connection is no good.
@@ -55,7 +44,7 @@ module Sip2
55
44
  raise
56
45
  end
57
46
  else
58
- # IO.select returns nil when the socket is not ready before timeout
47
+ # wait_writable returns nil when the socket is not ready before timeout
59
48
  # seconds have elapsed
60
49
  socket.close
61
50
  raise ConnectionTimeout
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sip2
4
+ module Responses
5
+ #
6
+ # Sip2 Base response
7
+ #
8
+ class Base
9
+ attr_reader :raw_response
10
+
11
+ def initialize(raw_response)
12
+ @raw_response = raw_response
13
+ end
14
+
15
+ TIME_ZONE_LOOKUP_TABLE = {
16
+ '-12:00' => %w[Y],
17
+ '-11:00' => %w[X BST],
18
+ '-10:00' => %w[W HST BDT],
19
+ '-09:00' => %w[V YST HDT],
20
+ '-08:00' => %w[U PST YDT],
21
+ '-07:00' => %w[T MST PDT],
22
+ '-06:00' => %w[S CST MDT],
23
+ '-05:00' => %w[R EST CDT],
24
+ '-04:00' => %w[Q AST EDT],
25
+ '-03:00' => %w[P ADT],
26
+ '-02:00' => %w[O],
27
+ '-01:00' => %w[N],
28
+ '+00:00' => %w[Z GMT WET],
29
+ '+01:00' => %w[A CET BST],
30
+ '+02:00' => %w[B EET],
31
+ '+03:00' => %w[C],
32
+ '+04:00' => %w[D],
33
+ '+05:00' => %w[E],
34
+ '+06:00' => %w[F],
35
+ '+07:00' => %w[G],
36
+ '+08:00' => %w[H SST WST],
37
+ '+09:00' => %w[I JST],
38
+ '+10:00' => %w[K JDT],
39
+ '+11:00' => %w[L],
40
+ '+12:00' => %w[M NZST],
41
+ '+13:00' => %w[NZDT]
42
+ }.freeze
43
+
44
+ LANGUAGE_LOOKUP_TABLE = {
45
+ '000' => 'Unknown',
46
+ '001' => 'English',
47
+ '002' => 'French',
48
+ '003' => 'German',
49
+ '004' => 'Italian',
50
+ '005' => 'Dutch',
51
+ '006' => 'Swedish',
52
+ '007' => 'Finnish',
53
+ '008' => 'Spanish',
54
+ '009' => 'Danish',
55
+ '010' => 'Portuguese',
56
+ '011' => 'Canadian-French',
57
+ '012' => 'Norwegian',
58
+ '013' => 'Hebrew',
59
+ '014' => 'Japanese',
60
+ '015' => 'Russian',
61
+ '016' => 'Arabic',
62
+ '017' => 'Polish',
63
+ '018' => 'Greek',
64
+ '019' => 'Chinese',
65
+ '020' => 'Korean',
66
+ '021' => 'North American Spanish',
67
+ '022' => 'Tamil',
68
+ '023' => 'Malay',
69
+ '024' => 'United Kingdom',
70
+ '025' => 'Icelandic',
71
+ '026' => 'Belgian',
72
+ '027' => 'Taiwanese'
73
+ }.freeze
74
+
75
+ protected
76
+
77
+ DATE_MATCH_REGEX = '(\d{4})(\d{2})(\d{2})(.{4})(\d{2})(\d{2})(\d{2})'
78
+
79
+ def parse_fixed_response(position, count = 1)
80
+ raw_response[/\A#{self.class::RESPONSE_ID}.{#{position}}(.{#{count}})/, 1]
81
+ end
82
+
83
+ def parse_fixed_boolean(position)
84
+ parse_fixed_response(position) == 'Y'
85
+ end
86
+
87
+ def parse_optional_boolean(message_id)
88
+ raw_response[/(?:\A.{#{self.class::FIXED_LENGTH_CHARS}}|\|)#{message_id}([YN])\|/, 1] == 'Y'
89
+ end
90
+
91
+ def parse_text(message_id)
92
+ raw_response[/(?:\A.{#{self.class::FIXED_LENGTH_CHARS}}|\|)#{message_id}(.*?)\|/, 1]
93
+ end
94
+
95
+ def parse_datetime(position)
96
+ match = raw_response.match(/\A#{self.class::RESPONSE_ID}.{#{position}}#{DATE_MATCH_REGEX}/)
97
+ return unless match
98
+
99
+ _, year, month, day, zone, hour, minute, second = match.to_a
100
+ Time.new(
101
+ year.to_i, month.to_i, day.to_i,
102
+ hour.to_i, minute.to_i, second.to_i,
103
+ offset_from_zone(zone)
104
+ )
105
+ end
106
+
107
+ def offset_from_zone(zone)
108
+ zone.strip!
109
+ lookup = TIME_ZONE_LOOKUP_TABLE.find { |_, v| v.include? zone }
110
+ lookup ? lookup.first : '+00:00'
111
+ end
112
+ end
113
+ end
114
+ end