wechat-bot2 0.1.1

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,117 @@
1
+ require "http"
2
+
3
+ module WeChat::Bot
4
+ module HTTP
5
+ # 可保存 Cookies 的 HTTP 请求类
6
+ #
7
+ # 简单实现 Python 版本 {http://docs.python-requests.org/zh_CN/latest/user/advanced.html#session-objects requests.Session()}
8
+ class Session
9
+ # @return [HTTP::CookieJar]
10
+ attr_reader :cookies
11
+
12
+ def initialize(bot)
13
+ @bot = bot
14
+
15
+ load_cookies(@bot.config.cookies)
16
+ end
17
+
18
+ # @return [HTTP::Response]
19
+ def get(url, options = {})
20
+ request(:get, url, options)
21
+ end
22
+
23
+ # @return [HTTP::Response]
24
+ def post(url, options = {})
25
+ request(:post, url, options)
26
+ end
27
+
28
+ # @return [HTTP::Response]
29
+ def put(url, options = {})
30
+ request(:put, url, options)
31
+ end
32
+
33
+ # @return [HTTP::Response]
34
+ def delete(url, options = {})
35
+ request(:delete, url, options)
36
+ end
37
+
38
+ # @return [HTTP::Response]
39
+ def request(verb, url, options = {})
40
+ prepare_request(url)
41
+
42
+ if options[:timeout]
43
+ connect_timeout, read_timeout = options.delete(:timeout)
44
+ @client = @client.timeout(connect: connect_timeout, read: read_timeout)
45
+ end
46
+
47
+ response = @client.request(verb, url, options)
48
+ update_cookies(response.cookies)
49
+
50
+ @bot.logger.verbose "[#{verb.upcase}] #{url}"
51
+ @bot.logger.verbose "Options: #{options}"
52
+ @bot.logger.verbose "Response: #{response.body}"
53
+
54
+ response
55
+ end
56
+
57
+ private
58
+
59
+ # 组装 request 基础请求参数
60
+ #
61
+ # - 设置 User-Agent
62
+ # - 设置 Cooklies
63
+ #
64
+ # @api private
65
+ # @param [String] url
66
+ # @return [HTTP::Request]
67
+ def prepare_request(url)
68
+ @client = ::HTTP.headers(user_agent: @bot.config.user_agent, "Range" => "bytes=0-")
69
+ return @client if @cookies.nil?
70
+ return @client = @client.cookies(@cookies)
71
+
72
+ # TODO: 优化处理同一顶级域名的 cookies
73
+ # uri = URI(url)
74
+ # unless @cookies.empty?(uri)
75
+ # cookies = @cookies.clone
76
+ # cookies.cookies.each do |cookie|
77
+ # cookies.delete(cookie) if uri.host != cookie.domain
78
+ # end
79
+
80
+ # unless cookies.empty?(uri)
81
+ # @client = @client.cookies(@cookies)
82
+ # end
83
+ # end
84
+
85
+ end
86
+
87
+ # 加载外部的 Cookies 数据
88
+ #
89
+ # @api private
90
+ # @param [String, HTTP::CooieJar] cookies
91
+ # @return [void]
92
+ def load_cookies(cookies)
93
+ @cookies = ::HTTP::CookieJar.new
94
+ return if cookies.nil?
95
+
96
+ if cookies.is_a?(String)
97
+ @cookies.load(cookies) if File.exist?(cookies)
98
+ else
99
+ @cookies.add(cookies)
100
+ end
101
+ end
102
+
103
+ # 请求后更新存储的 Cookies 数据
104
+ #
105
+ # @api private
106
+ # @param [String, HTTP::CooieJar] cookies
107
+ # @return [void]
108
+ def update_cookies(cookies)
109
+ return @cookies = cookies if @cookies.nil? || @cookies.empty?
110
+
111
+ cookies.cookies.each do |cookie|
112
+ @cookies.add(cookie)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,102 @@
1
+ require "colorize"
2
+
3
+ module WeChat::Bot
4
+ class Logger
5
+ LEVELS = [:verbose, :debug, :info, :warn, :error, :fatal]
6
+
7
+ # @return [Symbol]
8
+ attr_accessor :level
9
+
10
+ # @return [Mutex]
11
+ attr_reader :mutex
12
+
13
+ # @return [IO]
14
+ attr_reader :output
15
+
16
+ def initialize(output, bot)
17
+ @output = output
18
+ @bot = bot
19
+ @mutex = Mutex.new
20
+ @level = :info
21
+ end
22
+
23
+ def verbose(message)
24
+ log(:verbose, message)
25
+ end
26
+
27
+ def debug(message)
28
+ log(:debug, message)
29
+ end
30
+
31
+ def info(message)
32
+ log(:info, message)
33
+ end
34
+
35
+ def warn(message)
36
+ log(:warn, message)
37
+ end
38
+
39
+ def error(message)
40
+ log(:error, message)
41
+ end
42
+
43
+ def fatal(exception)
44
+ message = ["#{exception.backtrace.first}: #{exception.message} (#{exception.class})"]
45
+ message.concat(exception.backtrace[1..-1].map {|s| "\t" + s})
46
+ log(:fatal, message.join("\n"))
47
+ end
48
+
49
+ def log(level, message)
50
+ return unless can_log?(level)
51
+ return if message.to_s.empty?
52
+
53
+ @mutex.synchronize do
54
+ message = format_message(format_general(message), level)
55
+ @output.puts message
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def can_log?(level)
62
+ @level = :verbose if @bot.config.verbose
63
+ LEVELS.index(level) >= LEVELS.index(@level)
64
+ end
65
+
66
+ def format_general(message)
67
+ message
68
+ end
69
+
70
+ def format_message(message, level)
71
+ send("format_#{level}", message)
72
+ end
73
+
74
+ def format_verbose(message)
75
+ "VERBOSE [#{timestamp}] #{message.colorize(:light_black)}"
76
+ end
77
+
78
+ def format_debug(message)
79
+ "DEBUG [#{timestamp}] #{message.colorize(:light_black)}"
80
+ end
81
+
82
+ def format_info(message)
83
+ "INFO [#{timestamp}] #{message}"
84
+ end
85
+
86
+ def format_warn(message)
87
+ "WRAN [#{timestamp}] #{message.colorize(:yellow)}"
88
+ end
89
+
90
+ def format_error(message)
91
+ "ERROR [#{timestamp}] #{message.colorize(:light_red)}"
92
+ end
93
+
94
+ def format_fatal(message)
95
+ "FATAL [#{timestamp}] #{message.colorize(:red)}"
96
+ end
97
+
98
+ def timestamp
99
+ Time.now.strftime("%Y-%m-%d %H:%M:%S.%2N")
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,282 @@
1
+ require "wechat/bot/message_data/share_card"
2
+
3
+ module WeChat::Bot
4
+ # 微信消息
5
+ class Message
6
+ # 消息类型
7
+ module Kind
8
+ Text = :text
9
+ Image = :image
10
+ Voice = :voice
11
+ ShortVideo = :short_video
12
+ Emoticon = :emoticon
13
+ ShareCard = :share_link
14
+ # RedPacage = :red_package
15
+ # BusinessCard = :business_card
16
+ # MusicLink = :music_link
17
+ Verify = :verify
18
+ System = :system
19
+ Unkown = :unkown
20
+ end
21
+
22
+ GROUP_MESSAGE_REGEX = /^(@\w+):<br\/>(.*)$/
23
+ AT_MESSAGE_REGEX = /@([^\s]+) (.*)/
24
+
25
+ # 原始消息
26
+ # @return [Hash<Object, Object>]
27
+ attr_reader :raw
28
+
29
+ # 事件列表
30
+ # @return [Array<Symbol>]
31
+ attr_reader :events
32
+
33
+ # @return [Core]
34
+ attr_reader :bot
35
+
36
+ # @return [Time]
37
+ attr_reader :time
38
+
39
+ # 消息类型
40
+ # @return [Message::Kind]
41
+ attr_reader :kind
42
+
43
+ # 消息来源
44
+ # @return [Contact::Kind]
45
+ attr_reader :source
46
+
47
+ # 消息发送者
48
+ #
49
+ # 用户或者群组
50
+ # @return [Contact]
51
+ attr_reader :from
52
+
53
+ # 消息正文
54
+ # @return [String]
55
+ attr_reader :message
56
+
57
+ attr_reader :media_id
58
+
59
+ attr_reader :meta_data
60
+
61
+ def initialize(raw, bot)
62
+ @raw = raw
63
+ @bot = bot
64
+
65
+ @events = []
66
+ @time = Time.now
67
+ @statusmsg_mode = nil
68
+
69
+ parse
70
+
71
+ @bot.logger.verbose "Message Raw: #{@raw}"
72
+ end
73
+
74
+ # 回复消息
75
+ def reply(text, **args)
76
+ to_user = args[:username] || @from.username
77
+ to_user = @bot.contact_list.find(nickname: args[:nickname]) if args[:nickname]
78
+
79
+ message_type = args[:type] || :text
80
+
81
+ # if @bot.config.special_users.include?(to_user) && to_user != 'filehelper'
82
+ # @bot.logger.error "特殊账户无法回复: #{to_user}"
83
+ # raise NoReplyException, "特殊账户无法回复: #{to_user}"
84
+ # end
85
+
86
+ @bot.client.send(message_type, to_user, text)
87
+ end
88
+
89
+ # 解析微信消息
90
+ #
91
+ # @return [void]
92
+ def parse
93
+ parse_source
94
+ parse_kind
95
+
96
+ message = @raw["Content"].convert_emoji
97
+ message = CGI.unescape_html(message) if @kinde != Message::Kind::Text
98
+ if match = group_message(message)
99
+ # from_username = match[0]
100
+ message = match[1]
101
+ end
102
+
103
+ @message = message
104
+ # TODO: 来自于特殊账户无法获取联系人信息,需要单独处理
105
+ @from = @bot.contact_list.find(username: @raw["FromUserName"])
106
+ parse_emoticon if @kind == Message::Kind::Emoticon
107
+
108
+ case @kind
109
+ when Message::Kind::ShareCard
110
+ @meta_data = MessageData::ShareCard.parse(@message)
111
+ end
112
+
113
+ parse_events
114
+ end
115
+
116
+ # 消息匹配
117
+ #
118
+ # @param [String, Regex, Pattern] regexp 匹配规则
119
+ # @param [String, Symbol] type 消息类型
120
+ # @return [MatchData] 匹配结果
121
+ def match(regexp, type)
122
+ # text = ""
123
+ # case type
124
+ # when :ctcp
125
+ # text = ctcp_message
126
+ # when :action
127
+ # text = action_message
128
+ # else
129
+ # text = message.to_s
130
+ # type = :other
131
+ # end
132
+
133
+ # if strip_colors
134
+ # text = Cinch::Formatting.unformat(text)
135
+ # end
136
+
137
+ @message.match(regexp)
138
+ end
139
+
140
+ def at_message?
141
+ @at_mesage == true
142
+ end
143
+
144
+ # 解析消息来源
145
+ #
146
+ # 特殊账户/群聊/公众号/用户
147
+ #
148
+ # @return [void]
149
+ def parse_source
150
+ @source = if @bot.config.special_users.include?(@raw["FromUserName"])
151
+ # 特殊账户
152
+ Contact::Kind::Special
153
+ elsif @raw["FromUserName"].include?("@@")
154
+ # 群聊
155
+ Contact::Kind::Group
156
+ elsif (@raw["RecommendInfo"]["VerifyFlag"] & 8) != 0
157
+ # 公众号
158
+ Contact::Kind::MP
159
+ else
160
+ # 普通用户
161
+ Contact::Kind::User
162
+ end
163
+ end
164
+
165
+ # 解析消息类型
166
+ #
167
+ # - 1: Text 文本消息
168
+ # - 3: Image 图片消息
169
+ # - 34: Voice 语言消息
170
+ # - 37: 验证消息
171
+ # - 42: BusinessCard 名片消息
172
+ # - 47: Emoticon 微信表情
173
+ # - 49: ShareCard 分享链接消息
174
+ # - 62: ShortVideo 短视频消息
175
+ # - 1000: System 系统消息
176
+ # - Unkown 未知消息
177
+ #
178
+ # @return [void]
179
+ def parse_kind
180
+ @kind = case @raw["MsgType"]
181
+ when 1
182
+ Message::Kind::Text
183
+ when 3
184
+ Message::Kind::Image
185
+ when 34
186
+ Message::Kind::Voice
187
+ when 37
188
+ Message::Kind::Verify
189
+ when 42
190
+ Message::Kind::BusinessCard
191
+ when 62
192
+ Message::Kind::ShortVideo
193
+ when 47
194
+ Message::Kind::Emoticon
195
+ when 49
196
+ Message::Kind::ShareCard
197
+ when 10000
198
+ Message::Kind::System
199
+ else
200
+ Message::Kind::Unkown
201
+ end
202
+ end
203
+
204
+ # 解析 Handler 的事件
205
+ #
206
+ # - `:message` 用户消息
207
+ # - `:text` 文本消息
208
+ # - `:image` 图片消息
209
+ # - `:voice` 语音消息
210
+ # - `:short_video` 短视频消息
211
+ # - `:group` 群聊消息
212
+ # - `:at_message` @ 消息
213
+ #
214
+ # @return [void]
215
+ def parse_events
216
+ @events << :message
217
+ @events << @kind
218
+ @events << @source
219
+
220
+ @at_mesage = false
221
+ if @source == :group && @raw["Content"] =~ /@([^\s]+)\s+(.*)/
222
+ @events << :at_message
223
+ @at_mesage = true
224
+ end
225
+ end
226
+
227
+ # 解析表情
228
+ #
229
+ # 表情分为两种:
230
+ # 1. 微信商店表情
231
+ # 1. 自定义表情
232
+ #
233
+ # @return [void]
234
+ def parse_emoticon
235
+ if @message.empty?
236
+ @media_id = @raw["MediaId"]
237
+ # TODO: 解决微信商店表情
238
+ # file = @bot.client.download_image(@raw["NewMsgId"])
239
+ else
240
+ data = MultiXml.parse(@message)
241
+ @media_id = data["msg"]["emoji"]["md5"]
242
+ end
243
+ end
244
+
245
+ def parse_share
246
+ # TODO: 完成解析
247
+ data = MultiXml.parse(@message)
248
+ end
249
+
250
+ # 解析用户的群消息
251
+ #
252
+ # 群消息格式:
253
+ # @FromUserName:<br>Message
254
+ #
255
+ # @param [String] message 原始消息
256
+ # @return [Array<Object>] 返回两个值的数组
257
+ # - 0 from_username
258
+ # - 1 message
259
+ def group_message(message)
260
+ if match = GROUP_MESSAGE_REGEX.match(message)
261
+ return [match[1], at_message(match[2])]
262
+ end
263
+
264
+ false
265
+ end
266
+
267
+ # 尝试解析群聊中的 @ 消息
268
+ #
269
+ # 群消息格式:
270
+ # @ToNickNameUserName Message
271
+ #
272
+ # @param [String] message 原始消息
273
+ # @return [String] 文本消息,如果不是 @ 消息返回原始消息
274
+ def at_message(message)
275
+ if match = AT_MESSAGE_REGEX.match(message)
276
+ return match[2].strip
277
+ end
278
+
279
+ message
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,46 @@
1
+ module WeChat::Bot::MessageData
2
+ class ShareCard
3
+ def self.parse(raw)
4
+ self.new(raw)
5
+ end
6
+
7
+ # @return [String]
8
+ attr_reader :title
9
+
10
+ # @return [String]
11
+ attr_reader :link
12
+
13
+ # @return [String]
14
+ attr_reader :description
15
+
16
+ # @return [String, Nil]
17
+ attr_reader :thumb_image
18
+
19
+ # @return [String]
20
+ attr_reader :from_user
21
+
22
+ # @return [String, Nil]
23
+ attr_reader :app
24
+
25
+ # @return [Hash<Symbol, String>, Hash<Symbol, Nil>]
26
+ attr_reader :mp
27
+
28
+ def initialize(raw)
29
+ @raw = MultiXml.parse(raw.gsub("<br/>", ""))
30
+ parse
31
+ end
32
+
33
+ def parse
34
+ @title = @raw["msg"]["appmsg"]["title"]
35
+ @link = @raw["msg"]["appmsg"]["url"]
36
+ @description = @raw["msg"]["appmsg"]["des"]
37
+ @thumb_image = @raw["msg"]["appmsg"]["thumb_url"]
38
+ @from_user = @raw["msg"]["fromusername"]
39
+ @app = @raw["msg"]["appname"]
40
+ @mp = {
41
+ username: @raw["msg"]["sourceusername"],
42
+ nickname: @raw["msg"]["sourcedisplayname"],
43
+ }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,63 @@
1
+ module WeChat::Bot
2
+ # 消息匹配
3
+ class Pattern
4
+ # @param [String, Regexp, NilClass, Proc, #to_s] obj 匹配规则
5
+ # @return [Regexp, nil]
6
+ def self.obj_to_r(obj, anchor = nil)
7
+ case obj
8
+ when Regexp, NilClass
9
+ return obj
10
+ else
11
+ escaped = Regexp.escape(obj.to_s)
12
+ case anchor
13
+ when :start
14
+ return Regexp.new("^" + escaped)
15
+ when :end
16
+ return Regexp.new(escaped + "$")
17
+ when nil
18
+ return Regexp.new(Regexp.escape(obj.to_s))
19
+ end
20
+ end
21
+ end
22
+
23
+ def self.resolve_proc(obj, msg = nil)
24
+ if obj.is_a?(Proc)
25
+ return resolve_proc(obj.call(msg), msg)
26
+ else
27
+ return obj
28
+ end
29
+ end
30
+
31
+ def self.generate(type, argument)
32
+ case type
33
+ when :ctcp
34
+ Pattern.new(/^/, /#{Regexp.escape(argument.to_s)}(?:$| .+)/, nil)
35
+ else
36
+ raise ArgumentError, "Unsupported type: #{type.inspect}"
37
+ end
38
+ end
39
+
40
+ attr_reader :prefix
41
+ attr_reader :suffix
42
+ attr_reader :pattern
43
+
44
+ def initialize(prefix, pattern, suffix)
45
+ @prefix, @pattern, @suffix = prefix, pattern, suffix
46
+ end
47
+
48
+ def to_r(msg = nil)
49
+ pattern = Pattern.resolve_proc(@pattern, msg)
50
+
51
+ case pattern
52
+ when Regexp, NilClass
53
+ prefix = Pattern.obj_to_r(Pattern.resolve_proc(@prefix, msg), :start)
54
+ suffix = Pattern.obj_to_r(Pattern.resolve_proc(@suffix, msg), :end)
55
+ /#{prefix}#{pattern}#{suffix}/
56
+ else
57
+ prefix = Pattern.obj_to_r(Pattern.resolve_proc(@prefix, msg))
58
+ suffix = Pattern.obj_to_r(Pattern.resolve_proc(@suffix, msg))
59
+ /^#{prefix}#{Pattern.obj_to_r(pattern)}#{suffix}$/
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,5 @@
1
+ module WeChat
2
+ module Bot
3
+ VERSION = "0.1.1"
4
+ end
5
+ end
data/lib/wechat/bot.rb ADDED
@@ -0,0 +1,12 @@
1
+ require "wechat/bot/version"
2
+ require "wechat/bot/ext/wechat_emoji_string"
3
+
4
+ require "wechat/bot/core"
5
+ require "wechat/bot/client"
6
+ require "wechat/bot/exception"
7
+
8
+ module WeChat::Bot
9
+ def self.new(&block)
10
+ WeChat::Bot::Core.new(&block)
11
+ end
12
+ end
data/lib/wechat-bot.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'wechat/bot'
data/lib/wechat_bot.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'wechat/bot'
@@ -0,0 +1,40 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'wechat/bot/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "wechat-bot2"
8
+ spec.version = WeChat::Bot::VERSION
9
+ spec.authors = ["icyleaf"]
10
+ spec.email = ["icyleaf.cn@gmail.com"]
11
+
12
+ spec.summary = "WeChat Bot for Ruby"
13
+ spec.description = "WeChat Bot for Ruby with personal account"
14
+ spec.homepage = "https://github.com/icyleaf/wechat-bot"
15
+ spec.license = "MIT"
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(%r{^(test|spec|features)/})
19
+ end
20
+ spec.bindir = "exe"
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ["lib"]
23
+
24
+ spec.required_ruby_version = ">= 2.1.0"
25
+
26
+ spec.add_development_dependency "bundler", "~> 1.14"
27
+ spec.add_development_dependency "rake", "~> 10.0"
28
+ spec.add_development_dependency "rspec", "~> 3.0"
29
+ spec.add_development_dependency "rubocop", "~> 0.53.0"
30
+ spec.add_development_dependency "webmock"
31
+ spec.add_development_dependency "awesome_print"
32
+
33
+ spec.add_dependency "colorize", "~> 0.8.1"
34
+ spec.add_dependency "http", "~> 2.2.2"
35
+ spec.add_dependency "rqrcode", "~> 0.10.1"
36
+ spec.add_dependency "multi_xml", "~> 0.6.0"
37
+ # spec.add_dependency "representable", "~> 3.0.4"
38
+ # spec.add_dependency "roxml", "~> 3.3.1"
39
+ # spec.add_dependency "gemoji"
40
+ end