tg_mq 0.0.3

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: f65f989683ba5ccbb0f3862df8bf42c4e28aeedc5046b2a19fafd0a3a2ffe99f
4
+ data.tar.gz: b19a7af3a43caf7146825cb8dab01b6148ee8bc570e78ef4d8c7e5418d700e1a
5
+ SHA512:
6
+ metadata.gz: afd1732c0334d14dd4c827dd9de4efe471b0e36d096c443d527119dd68ee7b270fa7fab33156f5738d7bf0cb8e6e178b23616dc01c0d92ff4586c00347bd2565
7
+ data.tar.gz: 197bf2b88aaf2550c8e20ca6f31414561a7cf7ef6b1d7037b522e3e1a936dcaf1791b4ab0bca9ea34501956534430c6f430c31095c1fffc14e0ba602e8736676
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in aggredator-api.gemspec
6
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,82 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ tg_mq (0.0.1)
5
+ activesupport (~> 6.0)
6
+ logger
7
+ telegram-bot-ruby
8
+ timeouter
9
+ zeitwerk
10
+
11
+ GEM
12
+ remote: https://rubygems.org/
13
+ specs:
14
+ activesupport (6.1.7.10)
15
+ concurrent-ruby (~> 1.0, >= 1.0.2)
16
+ i18n (>= 1.6, < 2)
17
+ minitest (>= 5.1)
18
+ tzinfo (~> 2.0)
19
+ zeitwerk (~> 2.3)
20
+ bigdecimal (3.2.2)
21
+ byebug (12.0.0)
22
+ concurrent-ruby (1.3.5)
23
+ dry-core (1.1.0)
24
+ concurrent-ruby (~> 1.0)
25
+ logger
26
+ zeitwerk (~> 2.6)
27
+ dry-inflector (1.2.0)
28
+ dry-logic (1.6.0)
29
+ bigdecimal
30
+ concurrent-ruby (~> 1.0)
31
+ dry-core (~> 1.1)
32
+ zeitwerk (~> 2.6)
33
+ dry-struct (1.8.0)
34
+ dry-core (~> 1.1)
35
+ dry-types (~> 1.8, >= 1.8.2)
36
+ ice_nine (~> 0.11)
37
+ zeitwerk (~> 2.6)
38
+ dry-types (1.8.2)
39
+ bigdecimal (~> 3.0)
40
+ concurrent-ruby (~> 1.0)
41
+ dry-core (~> 1.0)
42
+ dry-inflector (~> 1.0)
43
+ dry-logic (~> 1.4)
44
+ zeitwerk (~> 2.6)
45
+ faraday (2.13.1)
46
+ faraday-net_http (>= 2.0, < 3.5)
47
+ json
48
+ logger
49
+ faraday-multipart (1.1.0)
50
+ multipart-post (~> 2.0)
51
+ faraday-net_http (3.4.0)
52
+ net-http (>= 0.5.0)
53
+ i18n (1.14.7)
54
+ concurrent-ruby (~> 1.0)
55
+ ice_nine (0.11.2)
56
+ json (2.12.2)
57
+ logger (1.7.0)
58
+ minitest (5.25.5)
59
+ multipart-post (2.4.1)
60
+ net-http (0.6.0)
61
+ uri
62
+ telegram-bot-ruby (2.4.0)
63
+ dry-struct (~> 1.6)
64
+ faraday (~> 2.0)
65
+ faraday-multipart (~> 1.0)
66
+ zeitwerk (~> 2.6)
67
+ timeouter (0.1.3.38794)
68
+ tzinfo (2.0.6)
69
+ concurrent-ruby (~> 1.0)
70
+ uri (1.0.3)
71
+ zeitwerk (2.7.3)
72
+
73
+ PLATFORMS
74
+ ruby
75
+ x86_64-linux
76
+
77
+ DEPENDENCIES
78
+ byebug
79
+ tg_mq!
80
+
81
+ BUNDLED WITH
82
+ 2.6.2
data/bin/tgmq ADDED
@@ -0,0 +1,123 @@
1
+ #!/bin/env ruby
2
+
3
+ require 'optparse'
4
+ require 'tg_mq'
5
+
6
+ UTIL = File.basename(__FILE__)
7
+ SELF = File.absolute_path(__FILE__)
8
+
9
+ Thread.abort_on_exception = true
10
+
11
+ [STDIN, STDOUT, STDERR].each do |io|
12
+ io.sync = true
13
+ rescue StandardError
14
+ end
15
+
16
+ @opts = {
17
+ itoken: nil,
18
+ otoken: nil,
19
+ channel: nil,
20
+ log_level: ENV.fetch('LOG_LEVEL', ::Logger::INFO)
21
+ }
22
+
23
+ parser = OptionParser.new do |o|
24
+ o.banner = "Usage: #{UTIL} [options]"
25
+
26
+ o.separator ''
27
+ o.separator 'TgMq options:'
28
+
29
+ o.on('-d', '--debug', 'Enable debug output. Overrides LOG_LEVEL environment variable') do
30
+ @opts[:log_level] = ::Logger::DEBUG
31
+ end
32
+
33
+ o.on('-s', '--silent', 'Disable info output. Overrides LOG_LEVEL environment variable') do
34
+ @opts[:log_level] = ::Logger::WARN
35
+ end
36
+
37
+ o.on('-i TOKEN', '--itoken TOKEN', String, 'Telegram Bot token for input channel') do |token|
38
+ @opts[:itoken] = token.strip
39
+ end
40
+
41
+ o.on('-o TOKEN', '--otoken TOKEN', String, 'Telegram Bot token for output channel') do |token|
42
+ @opts[:otoken] = token.strip
43
+ end
44
+
45
+ o.on('-c CHANNEL', '--channel CHANNEL', String, 'Telegram Bot chat_id to communicate through') do |channel|
46
+ @opts[:channel] = channel
47
+ end
48
+ end
49
+ @args = parser.parse!
50
+
51
+ TgMq.configure do |config|
52
+ config.logger.level = @opts[:log_level]
53
+ end
54
+
55
+ $conn = conn = TgMq::Connection.new(itoken: @opts[:itoken], otoken: @opts[:otoken], channel: @opts[:channel])
56
+
57
+ control_queue = $queue = queue = SizedQueue.new(50)
58
+
59
+ def terminate_now
60
+ $terminate = true
61
+ STDOUT.flush
62
+ STDIN.close
63
+ $conn.stop
64
+ $queue.push(:terminate)
65
+ end
66
+
67
+ Signal.trap 'TERM' do
68
+ $terminate = true
69
+ Thread.new do
70
+ warn('Terminating gracefully...')
71
+ terminate_now
72
+ end
73
+ end
74
+
75
+ Signal.trap 'INT' do
76
+ $terminate = true
77
+ Thread.new do
78
+ warn('Stopping gracefully...')
79
+ terminate_now
80
+ end
81
+ end
82
+
83
+ reader = Thread.new(control_queue) do |q, _c|
84
+ STDIN.binmode
85
+ loop do
86
+ q.push([:in, STDIN.readpartial(3000)])
87
+ rescue EOFError
88
+ q.push([:in, 'EOF', proc { q.push(:terminate) }])
89
+ break
90
+ end
91
+ rescue StandardError
92
+ q.push(:terminate)
93
+ end
94
+
95
+ remote = Thread.new(queue, conn) do |q, c|
96
+ STDOUT.binmode
97
+ c.subscribe do |line|
98
+ q.push([:out, line])
99
+ end
100
+ end
101
+
102
+ loop do
103
+ cmd, data, cb = queue.deq(timeout: 1)
104
+ if cmd == :terminate || $terminate
105
+ terminate_now
106
+ break
107
+ end
108
+
109
+ next if cmd.nil?
110
+
111
+ if cmd == :in
112
+ cb ? conn.enqueue_message(data, &cb) : conn.enqueue_message(data)
113
+ elsif cmd == :out
114
+ if data.strip == 'EOF'
115
+ terminate_now
116
+ else
117
+ STDOUT.write(data)
118
+ STDOUT.flush
119
+ end
120
+ end
121
+ end
122
+
123
+ [reader, remote].each(&:join)
data/lib/tg-mq.rb ADDED
@@ -0,0 +1 @@
1
+ require_relative './tg_mq'
@@ -0,0 +1,41 @@
1
+ require 'uri'
2
+
3
+ module TgMq
4
+ class Configuration
5
+ attr_accessor :logger
6
+
7
+ def initialize(logger = ::Logger.new(STDERR, formatter: Logger::Formatter.new))
8
+ @logger = logger.respond_to?(:tagged) ? logger : ActiveSupport::TaggedLogging.new(logger)
9
+ end
10
+
11
+ module Concern
12
+ extend ActiveSupport::Concern
13
+
14
+ included do |_klass|
15
+ end
16
+
17
+ class_methods do
18
+ # Instantiate the Configuration singleton
19
+ # or return it. Remember that the instance
20
+ # has attribute readers so that we can access
21
+ # the configured values
22
+ def configuration
23
+ @configuration ||= TgMq::Configuration.new
24
+ end
25
+
26
+ def config
27
+ configuration
28
+ end
29
+
30
+ # This is the configure block definition.
31
+ # The configuration method will return the
32
+ # Configuration singleton, which is then yielded
33
+ # to the configure block. Then it's just a matter
34
+ # of using the attribute accessors we previously defined
35
+ def configure
36
+ yield(configuration)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,122 @@
1
+ require 'base64'
2
+ require 'telegram/bot'
3
+ require 'timeouter'
4
+ require 'monitor'
5
+
6
+ module TgMq
7
+ class Connection
8
+ attr_reader :logger
9
+
10
+ def initialize(itoken:, otoken:, channel:, logger: TgMq.config.logger)
11
+ @logger = TgMq.setup_logger(logger).tagged(self.class)
12
+
13
+ @mx = Monitor.new
14
+
15
+ @itoken = itoken
16
+ @otoken = otoken
17
+ @channel = channel
18
+
19
+ @output_bot = ::Telegram::Bot::Client.new(@otoken, timeout: 5)
20
+ @output_shaper = OutputShaper.new(chunk_size: 4080)
21
+
22
+ @input_bot = ::Telegram::Bot::Client.new(@itoken, timeout: 5)
23
+ @input_shaper = InputShaper.new(logger: logger)
24
+
25
+ # Telegram Bot api alows 30 rps, we use only 20
26
+ @delay = 60.0 / 25.0
27
+ $r = 0
28
+
29
+ # @squeue = SizedQueue.new(10)
30
+ @threads = []
31
+ @threads << start_sender(@output_shaper, @output_bot, @delay)
32
+ end
33
+
34
+ def enqueue_message(data, &cb)
35
+ logger.debug("Enqueue message of #{data.size} bytes")
36
+ @output_shaper.enqueue_data(data, &cb)
37
+ end
38
+
39
+ def stopped?
40
+ @stopped
41
+ end
42
+
43
+ def stop(timeout = 2)
44
+ return if @stopped
45
+
46
+ @stopped = true
47
+ @output_bot.stop
48
+ @input_bot.stop
49
+
50
+ if wait_for_termination(timeout)
51
+ true
52
+ else
53
+ @threads.each(&:kill)
54
+ false
55
+ end
56
+ end
57
+
58
+ def wait_for_termination(timeout = 0)
59
+ Timeouter.run(timeout) do |t|
60
+ return true if @threads.all? do |thread|
61
+ thread.join(t.left)
62
+ end
63
+ end
64
+ end
65
+
66
+ def subscribe(&block)
67
+ @input_bot.listen do |message|
68
+ case message
69
+ when Telegram::Bot::Types::Message
70
+ logger.debug(123_123)
71
+ text = (message&.new_chat_title || message&.text || message&.pinned_message&.text).to_s
72
+ logger.debug "Receive raw: [#{text.first(15)}...] of #{text.size} bytes"
73
+
74
+ if (data = @input_shaper.pushdata(text))
75
+ decoded = Base64.strict_decode64(data)
76
+ logger.debug "Receive payload: of #{decoded.size} bytes"
77
+ block.call(decoded)
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ def start_sender(output_shaper, bot, delay)
84
+ next_send_at = Time.at(0)
85
+
86
+ Thread.new(output_shaper, bot) do |c, b|
87
+ until @stopped
88
+ break if @stopped
89
+ next(sleep 1) if Time.now < next_send_at
90
+
91
+ frame = c.deq(timeout: 1)
92
+ break if @stopped
93
+ next(sleep 1) if frame.nil?
94
+
95
+ logger.debug "Sending frame [#{frame.id}] of #{frame.data.size} bytes..."
96
+
97
+ response = tgretry { b.api.sendMessage(chat_id: @channel, text: frame.data) }
98
+ sleep 0.1
99
+ pin_response = tgretry { b.api.pinChatMessage(chat_id: @channel, message_id: response.message_id) }
100
+
101
+ begin
102
+ frame.callback&.call(pin_response)
103
+ rescue StandardError
104
+ nil
105
+ end
106
+
107
+ next_send_at = Time.now + delay * 2
108
+ end
109
+ end
110
+ end
111
+
112
+ def tgretry
113
+ yield
114
+ rescue Telegram::Bot::Exceptions::ResponseError => e
115
+ if e.error_code.to_i == 429
116
+ logger.warn 'Retry packet send in 5 seconds...'
117
+ sleep 5
118
+ retry
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,40 @@
1
+ module TgMq
2
+ class Frame
3
+ BEGIN_MSG = 'tgw'
4
+ BEGIN_RX = /^#{BEGIN_MSG}\[(?<seq>\d+):(?<msg_id>\d+):(?<num>\d+)\](?<msg>.*)/
5
+
6
+ END_MSG = ':wgt'
7
+ END_RX = /#{END_MSG}$/
8
+
9
+ attr_reader :seq, :msg_id, :num, :chunk, :callback
10
+
11
+ def initialize(seq, msg_id, num, chunk)
12
+ @seq = seq
13
+ @msg_id = msg_id
14
+ @num = num
15
+ @chunk = chunk
16
+ end
17
+
18
+ def self.parse(data)
19
+ if (md = BEGIN_RX.match(data))
20
+ new(md[:seq], md[:msg_id], md[:num].to_i, md[:msg])
21
+ end
22
+ end
23
+
24
+ def id
25
+ "#{message_id}:#{num}"
26
+ end
27
+
28
+ def message_id
29
+ "#{seq}:#{msg_id}"
30
+ end
31
+
32
+ def data
33
+ "#{BEGIN_MSG}[#{id}]#{chunk}"
34
+ end
35
+
36
+ def set_callback(&callback)
37
+ @callback = callback
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,35 @@
1
+ module TgMq
2
+ class InputShaper
3
+ attr_reader :logger
4
+
5
+ def initialize(logger: TgMq.config.logger)
6
+ @logger = TgMq.setup_logger(logger).tagged(self.class)
7
+
8
+ @frames = Hash.new do |h, message_id|
9
+ h[message_id] = {
10
+ buffer: '',
11
+ frames: []
12
+ }
13
+ end
14
+ end
15
+
16
+ def pushdata(text)
17
+ if (frame = Frame.parse(text))
18
+ logger.info "receive frame [#{frame.id}]"
19
+ frameset = @frames[frame.message_id]
20
+ data = frameset[:buffer] += frame.chunk
21
+ frameset[:frames] << frame
22
+
23
+ if data.end_with?(Frame::END_MSG)
24
+ @frames.delete(frame.message_id)
25
+ return data[0..-5]
26
+ end
27
+ else
28
+ logger.warn "skip [#{text.first(15)}]: not a frame"
29
+ end
30
+
31
+ nil
32
+ end
33
+
34
+ end
35
+ end
@@ -0,0 +1,28 @@
1
+ module TgMq
2
+ class Message
3
+ include Enumerable
4
+
5
+ def self.pack(seq, data, msg_id: Time.now.strftime('%L'), chunked: 4080)
6
+ (data + Frame::END_MSG).scan(/.{1,#{chunked}}/).each_with_index.map do |chunk, idx|
7
+ Frame.new(seq, msg_id, idx, chunk)
8
+ end
9
+ end
10
+
11
+ def initialize(seq, chunks, msg_id: Time.now.strftime('%L'))
12
+ @seq = seq
13
+ @msg_id = msg_id
14
+ @chunks = chunks
15
+ end
16
+
17
+ def each
18
+ if block_given?
19
+ idx = -1
20
+ @chunks.each do |chunk|
21
+ yield(Frame.new(@seq, @msg_id, idx += 1, chunk))
22
+ end
23
+ else
24
+ to_enum(:each)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,37 @@
1
+ module TgMq
2
+ class OutputShaper
3
+ attr_reader :chunk_size
4
+
5
+ def initialize(chunk_size: 110)
6
+ @chunk_size = chunk_size
7
+ @seq = 0
8
+ @queue = SizedQueue.new(10)
9
+ end
10
+
11
+ def enqueue_data(data, &callback)
12
+ frames = split_to_frames(next_seq!, data).to_a
13
+ frames.last&.set_callback(&callback)
14
+ frames.each do |frame|
15
+ @queue << frame
16
+ end
17
+ end
18
+
19
+ def deq(*args, **kwargs)
20
+ @queue.deq(*args, **kwargs)
21
+ end
22
+
23
+ protected
24
+
25
+ def next_seq!
26
+ @seq += 1
27
+ end
28
+
29
+ def split_to_frames(seq, data, msg_id: Time.now.strftime('%L'))
30
+ encoded = Base64.strict_encode64(data).strip
31
+
32
+ (encoded + Frame::END_MSG).scan(/.{1,#{chunk_size}}/).each_with_index.map do |chunk, idx|
33
+ Frame.new(seq, msg_id, idx, chunk)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,3 @@
1
+ module TgMq
2
+ VERSION = '0.0.3'
3
+ end
data/lib/tg_mq.rb ADDED
@@ -0,0 +1,20 @@
1
+ require 'logger'
2
+ require 'active_support/all'
3
+
4
+ require 'zeitwerk'
5
+
6
+ loader = Zeitwerk::Loader.for_gem
7
+ loader.ignore("#{__dir__}/tg-mq.rb")
8
+ loader.inflector.inflect(
9
+ 'tg_mq' => 'TgMq',
10
+ 'tg-mq' => 'TgMq'
11
+ )
12
+ loader.setup
13
+
14
+ module TgMq
15
+ include Configuration::Concern
16
+
17
+ def self.setup_logger(logger)
18
+ logger.respond_to?(:tagged) ? logger : ActiveSupport::TaggedLogging.new(logger)
19
+ end
20
+ end
metadata ADDED
@@ -0,0 +1,139 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tg_mq
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Samoilenko Yuri
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-06-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '6.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: logger
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: timeouter
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: telegram-bot-ruby
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: zeitwerk
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: byebug
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Message Queue backed by Telegram Chat
98
+ email:
99
+ - kinnalru@gmail.com
100
+ executables:
101
+ - tgmq
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - Gemfile
106
+ - Gemfile.lock
107
+ - bin/tgmq
108
+ - lib/tg-mq.rb
109
+ - lib/tg_mq.rb
110
+ - lib/tg_mq/configuration.rb
111
+ - lib/tg_mq/connection.rb
112
+ - lib/tg_mq/frame.rb
113
+ - lib/tg_mq/input_shaper.rb
114
+ - lib/tg_mq/message.rb
115
+ - lib/tg_mq/output_shaper.rb
116
+ - lib/tg_mq/version.rb
117
+ homepage:
118
+ licenses: []
119
+ metadata: {}
120
+ post_install_message:
121
+ rdoc_options: []
122
+ require_paths:
123
+ - lib
124
+ required_ruby_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '0'
129
+ required_rubygems_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ requirements: []
135
+ rubygems_version: 3.4.10
136
+ signing_key:
137
+ specification_version: 4
138
+ summary: Message Queue backed by Telegram Chat
139
+ test_files: []