wechat-bot 0.1.0.alpha

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: '090643c6759e35fa619b81b30055aa05e001d4fe'
4
+ data.tar.gz: 58a2fd57bc3967c6c552de30201e357968eec9a5
5
+ SHA512:
6
+ metadata.gz: 3b804920918ea5ddac3efb46183af8b0d1bae54d227c83f74f175d4bf907bb93286848ae10f70a497f7736ede1f98fe00902ad71765a22f52597e764d30db352
7
+ data.tar.gz: 8c2e0e348f2425a3542c4bd5fe317cd99167cfb1765a66338619b5f3d43a132ea7d86c48cfd059c54aa1b0e0879fc1cbd0d039c2ead6cddb1557694ae3546389
@@ -0,0 +1,15 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ /logs/
11
+
12
+
13
+ # rspec failure tracking
14
+ .rspec_status
15
+ .rvmrc
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,14 @@
1
+ Metrics/LineLength:
2
+ Max: 120
3
+
4
+ Metrics/MethodLength:
5
+ Max: 20
6
+
7
+ Metrics/ClassLength:
8
+ Max: 150
9
+
10
+ Style/AsciiComments:
11
+ Enabled: false
12
+
13
+ Style/SpecialGlobalVars:
14
+ Enabled: false
@@ -0,0 +1 @@
1
+ wecaht-bot
@@ -0,0 +1 @@
1
+ 2.4.1
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.4.0
5
+ before_install: gem install bundler -v 1.14.6
@@ -0,0 +1,65 @@
1
+ {
2
+ "version": "0.2.0",
3
+ "configurations": [
4
+ {
5
+ "name": "Rake",
6
+ "type": "Ruby",
7
+ "request": "launch",
8
+ "cwd": "${workspaceRoot}",
9
+ "program": "${workspaceRoot}/vendor/rake",
10
+ "useBundler": true,
11
+ "args": [
12
+ "bot"
13
+ ]
14
+ },
15
+ {
16
+ "name": "Listen for rdebug-ide",
17
+ "type": "Ruby",
18
+ "request": "attach",
19
+ "cwd": "${workspaceRoot}",
20
+ "remoteHost": "127.0.0.1",
21
+ "remotePort": "1234",
22
+ "remoteWorkspaceRoot": "${workspaceRoot}"
23
+ },
24
+ {
25
+ "name": "Rails server",
26
+ "type": "Ruby",
27
+ "request": "launch",
28
+ "cwd": "${workspaceRoot}",
29
+ "program": "${workspaceRoot}/bin/rails",
30
+ "args": [
31
+ "server"
32
+ ]
33
+ },
34
+ {
35
+ "name": "RSpec - all",
36
+ "type": "Ruby",
37
+ "request": "launch",
38
+ "cwd": "${workspaceRoot}",
39
+ "program": "${workspaceRoot}/bin/rspec",
40
+ "args": [
41
+ "-I",
42
+ "${workspaceRoot}"
43
+ ]
44
+ },
45
+ {
46
+ "name": "RSpec - active spec file only",
47
+ "type": "Ruby",
48
+ "request": "launch",
49
+ "cwd": "${workspaceRoot}",
50
+ "program": "${workspaceRoot}/bin/rspec",
51
+ "args": [
52
+ "-I",
53
+ "${workspaceRoot}",
54
+ "${file}"
55
+ ]
56
+ },
57
+ {
58
+ "name": "Cucumber",
59
+ "type": "Ruby",
60
+ "request": "launch",
61
+ "cwd": "${workspaceRoot}",
62
+ "program": "${workspaceRoot}/bin/cucumber"
63
+ }
64
+ ]
65
+ }
@@ -0,0 +1,3 @@
1
+ --protected
2
+ --exclude samples/
3
+ --files CODE_OF_CONDUCT.md,LICENSE.txt
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at icyleaf.cn@gmail.com. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in rbchat.gemspec
4
+ gemspec
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 icyleaf
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,23 @@
1
+ # wechat-bot
2
+
3
+ [![Build Status](https://img.shields.io/travis/icyleaf/wechat-bot.svg?style=flat)](https://travis-ci.org/icyleaf/wechat-bot)
4
+ [![Code Climate](https://img.shields.io/codeclimate/github/icyleaf/wechat-bot.svg?style=flat)](https://codeclimate.com/github/icyleaf/wechat-bot)
5
+ [![Inline docs](http://inch-ci.org/github/icyleaf/wechat-bot.svg?style=flat)](https://inch-ci.org/github/icyleaf/wechat-bot)
6
+ [![Gem version](https://img.shields.io/gem/v/wechat-bot.svg?style=flat)](https://rubygems.org/gems/wechat-bot)
7
+ [![License](https://img.shields.io/badge/license-MIT-red.svg?style=flat)](LICENSE.txt)
8
+
9
+ 微信机器人 Ruby 版本。
10
+
11
+ ## 快速上手
12
+
13
+ ```ruby
14
+ require 'wechat-bot'
15
+
16
+ bot = Wechat::Bot::Client.new do
17
+ on :message, "ping" do |message|
18
+ message.reply "PONG"
19
+ end
20
+ end
21
+
22
+ bot.start
23
+ ```
@@ -0,0 +1,39 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ require 'wechat_bot'
5
+ require 'awesome_print'
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ task :default => :spec
10
+
11
+ desc 'Run a sample wechat bot'
12
+ task :bot do
13
+ bot = WeChat::Bot.new do
14
+ configure do |c|
15
+ c.verbose = true
16
+ end
17
+
18
+ on :message do |m|
19
+ case m.kind
20
+ when WeChat::Bot::Message::Kind::Text
21
+ m.reply m.message
22
+ when WeChat::Bot::Message::Kind::Emoticon
23
+ if m.media_id.to_s.empty?
24
+ m.reply "微信商店的表情哪有私藏的好用!"
25
+ else
26
+ m.reply m.media_id, type: :emoticon
27
+ end
28
+ when WeChat::Bot::Message::Kind::ShareLink
29
+ m.reply "标题:#{m.meta_data.title}\n描述:#{m.meta_data.description}\n#{m.meta_data.link}"
30
+ when WeChat::Bot::Message::Kind::System
31
+ m.reply "系统消息:#{m.message}"
32
+ else
33
+ m.reply "[#{m.kind}]消息:#{m.message}"
34
+ end
35
+ end
36
+ end
37
+
38
+ bot.start
39
+ end
@@ -0,0 +1 @@
1
+ require "wechat/bot"
@@ -0,0 +1,11 @@
1
+ require "wechat/bot/ext/wechat_emoji_string"
2
+
3
+ require "wechat/bot/core"
4
+ require "wechat/bot/client"
5
+ require "wechat/bot/version"
6
+
7
+ module WeChat::Bot
8
+ def self.new(&block)
9
+ WeChat::Bot::Core.new(&block)
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ module WeChat::Bot
2
+ class CachedList
3
+ include Enumerable
4
+
5
+ def initialize(bot)
6
+ @bot = bot
7
+ @cache = {}
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def each(&block)
12
+ @cache.each_value(&block)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module WeChat::Bot
2
+ class Callback
3
+ # @return [Bot]
4
+ attr_reader :bot
5
+
6
+ def initialize(bot)
7
+ @bot = bot
8
+ end
9
+
10
+ # (see Bot#synchronize)
11
+ def synchronize(name, &block)
12
+ @bot.synchronize(name, &block)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,574 @@
1
+ require "rqrcode"
2
+ require "logger"
3
+ require "uri"
4
+
5
+ module WeChat::Bot
6
+ # 微信 API 类
7
+ class Client
8
+ def initialize(bot)
9
+ @bot = bot
10
+ clone!
11
+ end
12
+
13
+ # 微信登录
14
+ def login
15
+ return @bot.logger.info("你已经登录") if logged?
16
+
17
+ check_count = 0
18
+ until logged?
19
+ check_count += 1
20
+ @bot.logger.debug "尝试登录 (#{check_count})..."
21
+
22
+ uuid = qr_uuid
23
+ until uuid
24
+ @bot.logger.info "重新尝试获取登录二维码 ..."
25
+ sleep 1
26
+ end
27
+
28
+ show_qr_code(uuid)
29
+
30
+ until logged?
31
+ status, status_data = login_status(uuid)
32
+ case status
33
+ when :logged
34
+ @is_logged = true
35
+ store_login_data(status_data["redirect_uri"])
36
+ break
37
+ when :scaned
38
+ @bot.logger.info "请在手机微信确认登录 ..."
39
+ when :timeout
40
+ @bot.logger.info "扫描超时,重新获取登录二维码 ..."
41
+ break
42
+ end
43
+ end
44
+
45
+ break if logged?
46
+ end
47
+
48
+ @bot.logger.info "等待加载登录后所需资源 ..."
49
+ login_loading
50
+ update_notice_status
51
+
52
+ @bot.logger.info "用户 [#{@bot.profile.nickname}] 登录成功!"
53
+
54
+ start_runloop_thread
55
+ end
56
+
57
+ # Runloop 监听
58
+ def start_runloop_thread
59
+ @is_alive = true
60
+ retry_count = 0
61
+
62
+ Thread.new do
63
+ while alive?
64
+ begin
65
+ status = sync_check
66
+ if status[:retcode] == "0"
67
+ if status[:selector].nil?
68
+ @is_alive = false
69
+ elsif status[:selector] != "0"
70
+ sync_messages
71
+ end
72
+ elsif status[:retcode] == "1100"
73
+ @bot.logger.info("账户在手机上进行登出操作")
74
+ @is_alive = false
75
+ break
76
+ elsif [ "1101", "1102" ].include?(status[:retcode])
77
+ @bot.logger.info("账户在手机上进行登出或在其他地方进行登录操作操作")
78
+ @is_alive = false
79
+ break
80
+ end
81
+
82
+ retry_count = 0
83
+ rescue Exception => e
84
+ retry_count += 1
85
+ @bot.logger.fatal(e)
86
+ end
87
+
88
+ sleep 1
89
+ end
90
+
91
+ logout
92
+ end
93
+ end
94
+
95
+ # 获取生成二维码的唯一识别 ID
96
+ #
97
+ # @return [String]
98
+ def qr_uuid
99
+ params = {
100
+ "appid" => @bot.config.app_id,
101
+ "fun" => "new",
102
+ "lang" => "zh_CN",
103
+ "_" => timestamp,
104
+ }
105
+
106
+ @bot.logger.info "获取登录唯一标识 ..."
107
+ r = @session.get(File.join(@bot.config.auth_url, "jslogin") , params: params)
108
+ data = r.parse(:js)
109
+
110
+ return data["uuid"] if data["code"] == 200
111
+ end
112
+
113
+ # 获取二维码图片
114
+ def show_qr_code(uuid, renderer = "ansi")
115
+ @bot.logger.info "获取登录用扫描二维码 ... "
116
+ url = File.join(@bot.config.auth_url, "l", uuid)
117
+ qrcode = RQRCode::QRCode.new(url)
118
+
119
+ # image = qrcode.as_png(
120
+ # resize_gte_to: false,
121
+ # resize_exactly_to: false,
122
+ # fill: "white",
123
+ # color: "black",
124
+ # size: 120,
125
+ # border_modules: 4,
126
+ # module_px_size: 6,
127
+ # )
128
+ # IO.write(QR_FILENAME, image.to_s)
129
+
130
+ svg = qrcode.as_ansi(
131
+ light: "\033[47m",
132
+ dark: "\033[40m",
133
+ fill_character: " ",
134
+ quiet_zone_size: 2
135
+ )
136
+
137
+ puts svg
138
+ end
139
+
140
+ # 处理微信登录
141
+ #
142
+ # @return [Array]
143
+ def login_status(uuid)
144
+ timestamp = timestamp
145
+ params = {
146
+ "loginicon" => "true",
147
+ "uuid" => uuid,
148
+ "tip" => 0,
149
+ "r" => timestamp.to_i / 1579,
150
+ "_" => timestamp,
151
+ }
152
+
153
+ r = @session.get(File.join(@bot.config.auth_url, "cgi-bin/mmwebwx-bin/login"), params: params)
154
+ data = r.parse(:js)
155
+ status = case data["code"]
156
+ when 200 then :logged
157
+ when 201 then :scaned
158
+ when 408 then :waiting
159
+ else :timeout
160
+ end
161
+
162
+ [status, data]
163
+ end
164
+
165
+ # 保存登录返回的数据信息
166
+ #
167
+ # redirect_uri 有效时间是从扫码成功后算起大概是 300 秒,
168
+ # 在此期间可以重新登录,但获取的联系人和群 ID 会改变
169
+ def store_login_data(redirect_url)
170
+ host = URI.parse(redirect_url).host
171
+ r = @session.get(redirect_url)
172
+ data = r.parse(:xml)
173
+
174
+ store(
175
+ skey: data["error"]["skey"],
176
+ sid: data["error"]["wxsid"],
177
+ uin: data["error"]["wxuin"],
178
+ device_id: "e#{rand.to_s[2..17]}",
179
+ pass_ticket: data["error"]["pass_ticket"],
180
+ )
181
+
182
+ @bot.config.servers.each do |server|
183
+ if host == server[:index]
184
+ update_servers(server)
185
+ break
186
+ end
187
+ end
188
+
189
+ raise RuntimeError, "没有匹配到对于的微信服务器: #{host}" unless store(:index_url)
190
+
191
+ r
192
+ end
193
+
194
+ # 微信登录后初始化工作
195
+ #
196
+ # 掉线后 300 秒可以重新使用此 api 登录获取的联系人和群ID保持不变
197
+ def login_loading
198
+ url = "#{store(:index_url)}/webwxinit?r=#{timestamp}"
199
+ r = @session.post(url, json: params_base_request)
200
+ data = r.parse(:json)
201
+
202
+ store(
203
+ sync_key: data["SyncKey"],
204
+ invite_start_count: data["InviteStartCount"].to_i,
205
+ )
206
+
207
+ # 保存当前用户信息和最近聊天列表
208
+ @bot.profile.parse(data["User"])
209
+ @bot.contact_list.batch_sync(data["ContactList"])
210
+
211
+ r
212
+ end
213
+
214
+ # 更新通知状态(关闭手机提醒通知)
215
+ #
216
+ # 需要解密参数 Code 的值的作用,目前都用的是 3
217
+ def update_notice_status
218
+ url = "#{store(:index_url)}/webwxstatusnotify?lang=zh_CN&pass_ticket=#{store(:pass_ticket)}"
219
+ params = params_base_request.merge({
220
+ "Code" => 3,
221
+ "FromUserName" => @bot.profile.username,
222
+ "ToUserName" => @bot.profile.username,
223
+ "ClientMsgId" => timestamp
224
+ })
225
+
226
+ r = @session.post(url, json: params)
227
+ r
228
+ end
229
+
230
+ # 检查微信状态
231
+ #
232
+ # 状态会包含是否有新消息、用户状态变化等
233
+ #
234
+ # @return [Hash] 状态数据数组
235
+ # - :retcode
236
+ # - 0 成功
237
+ # - 1100 用户登出
238
+ # - 1101 用户在其他地方登录
239
+ # - :selector
240
+ # - 0 无消息
241
+ # - 2 新消息
242
+ # - 6 未知消息类型
243
+ # - 7 需要调用 {#sync_messages}
244
+ def sync_check
245
+ url = "#{store(:push_url)}/synccheck"
246
+ params = {
247
+ "r" => timestamp,
248
+ "skey" => store(:skey),
249
+ "sid" => store(:sid),
250
+ "uin" => store(:uin),
251
+ "deviceid" => store(:device_id),
252
+ "synckey" => params_sync_key,
253
+ "_" => timestamp,
254
+ }
255
+
256
+ r = @session.get(url, params: params, timeout: [10, 60])
257
+ data = r.parse(:js)["synccheck"]
258
+
259
+ # raise RuntimeException "微信数据同步异常,原始返回内容:#{r.to_s}" if data.nil?
260
+
261
+ @bot.logger.debug "HeartBeat: retcode/selector #{data[:retcode]}/#{data[:selector]}"
262
+ data
263
+ end
264
+
265
+ # 获取微信消息数据
266
+ #
267
+ # 根据 {#sync_check} 接口返回有数据时需要调用该接口
268
+ # @return [void]
269
+ def sync_messages
270
+ query = {
271
+ "sid" => store(:sid),
272
+ "skey" => store(:skey),
273
+ "pass_ticket" => store(:pass_ticket)
274
+ }
275
+ url = "#{store(:index_url)}/webwxsync?#{URI.encode_www_form(query)}"
276
+ params = params_base_request.merge({
277
+ "SyncKey" => store(:sync_key),
278
+ "rr" => "-#{timestamp}"
279
+ })
280
+
281
+ r = @session.post(url, json: params, timeout: [10, 60])
282
+ data = r.parse(:json)
283
+
284
+ @bot.logger.debug "Message: A/M/D/CM #{data["AddMsgCount"]}/#{data["ModContactCount"]}/#{data["DelContactCount"]}/#{data["ModChatRoomMemberCount"]}"
285
+
286
+ store(:sync_key, data["SyncCheckKey"])
287
+
288
+ # 更新已存在的群聊信息、增加新的群聊信息
289
+ @bot.contact_list.batch_sync(data["ModContactList"]) if data["ModContactCount"] > 0
290
+
291
+ if data["AddMsgCount"] > 0
292
+ data["AddMsgList"].each do |msg|
293
+ next if msg["FromUserName"] == @bot.profile.username
294
+
295
+ message = Message.new(msg, @bot)
296
+
297
+ events = [:message]
298
+ events.push(:text) if message.kind == Message::Kind::Text
299
+ events.push(:group) if msg["ToUserName"].include?("@@")
300
+
301
+ events.each do |event, *args|
302
+ @bot.handlers.dispatch(event, message, args)
303
+ end
304
+ end
305
+ end
306
+
307
+ data
308
+ end
309
+
310
+ # 获取所有联系人列表
311
+ #
312
+ # 好友、群组、订阅号、公众号和特殊号
313
+ #
314
+ # @return [Hash] 联系人列表
315
+ def contacts
316
+ query = {
317
+ "r" => timestamp,
318
+ "pass_ticket" => store(:pass_ticket),
319
+ "skey" => store(:skey)
320
+ }
321
+ url = "#{store(:index_url)}/webwxgetcontact?#{URI.encode_www_form(query)}"
322
+
323
+ r = @session.post(url, json: {})
324
+ data = r.parse(:json)
325
+
326
+ @bot.contact_list.batch_sync(data["MemberList"])
327
+ end
328
+
329
+ # 消息发送
330
+ #
331
+ # @param [Symbol] type 消息类型,未知类型默认走 :text
332
+ # - :text 文本
333
+ # - :emoticon 表情
334
+ # - :image 图片
335
+ # @param [String] username
336
+ # @param [String] content
337
+ # @return [Hash<Object,Object>] 发送结果状态
338
+ def send(type, username, content)
339
+ case type
340
+ when :emoticon
341
+ send_emoticon(username, content)
342
+ else
343
+ send_text(username, content)
344
+ end
345
+ end
346
+
347
+ # 发送消息
348
+ #
349
+ # @param [String] username 目标UserName
350
+ # @param [String] text 消息内容
351
+ # @return [Hash<Object,Object>] 发送结果状态
352
+ def send_text(username, text)
353
+ url = "#{store(:index_url)}/webwxsendmsg"
354
+ params = params_base_request.merge({
355
+ "Scene" => 0,
356
+ "Msg" => {
357
+ "Type" => 1,
358
+ "FromUserName" => @bot.profile.username,
359
+ "ToUserName" => username,
360
+ "Content" => text,
361
+ "LocalID" => timestamp,
362
+ "ClientMsgId" => timestamp,
363
+ },
364
+ })
365
+
366
+ r = @session.post(url, json: params)
367
+ r.parse(:json)
368
+ end
369
+
370
+ # 发送图片
371
+ #
372
+ # @param [String] username 目标 UserName
373
+ # @param [String, File] 图片名或图片文件
374
+ # @param [Hash] 非文本消息的参数(可选)
375
+ # @return [Boolean] 发送结果状态
376
+ # def send_image(username, image, media_id = nil)
377
+ # if media_id.nil?
378
+ # media_id = upload_file(image)
379
+ # end
380
+
381
+ # url = "#{store(:index_url)}/webwxsendmsgimg?fun=async&f=json"
382
+
383
+ # params = params_base_request.merge({
384
+ # "Scene" => 0,
385
+ # "Msg" => {
386
+ # "Type" => type,
387
+ # "FromUserName" => @bot.profile.username,
388
+ # "ToUserName" => username,
389
+ # "MediaId" => mediaId,
390
+ # "LocalID" => timestamp,
391
+ # "ClientMsgId" => timestamp,
392
+ # },
393
+ # })
394
+
395
+ # r = @session.post(url, json: params)
396
+ # r.parse(:json)
397
+ # end
398
+
399
+ # 发送表情
400
+ #
401
+ # 支持微信表情和自定义表情
402
+ #
403
+ # @param [String] username
404
+ # @param [String] emoticon_id
405
+ #
406
+ # @return [Hash<Object,Object>] 发送结果状态
407
+ def send_emoticon(username, emoticon_id)
408
+ query = {
409
+ 'fun' => 'sys',
410
+ 'pass_ticket' => store(:pass_ticket),
411
+ 'lang' => 'zh_CN'
412
+ }
413
+ url = "#{store(:index_url)}/webwxsendemoticon?#{URI.encode_www_form(query)}"
414
+ params = params_base_request.merge({
415
+ "Scene" => 0,
416
+ "Msg" => {
417
+ "Type" => 47,
418
+ 'EmojiFlag' => 2,
419
+ "FromUserName" => @bot.profile.username,
420
+ "ToUserName" => username,
421
+ "LocalID" => timestamp,
422
+ "ClientMsgId" => timestamp,
423
+ },
424
+ })
425
+
426
+ emoticon_key = emoticon_id.include?("@") ? "MediaId" : "EMoticonMd5"
427
+ params["Msg"][emoticon_key] = emoticon_id
428
+
429
+ r = @session.post(url, json: params)
430
+ r.parse(:json)
431
+ end
432
+
433
+ # 下载图片
434
+ #
435
+ # @param [String] message_id
436
+ # @return [TempFile]
437
+ def download_image(message_id)
438
+ url = "#{store(:index_url)}/webwxgetmsgimg"
439
+ params = {
440
+ "msgid" => message_id,
441
+ "skey" => store(:skey)
442
+ }
443
+
444
+ r = @session.get(url, params: params)
445
+ # body = r.body
446
+
447
+ # FIXME: 不知道什么原因,下载的是空字节
448
+ # 返回的 headers 是 {"Connection"=>"close", "Content-Length"=>"0"}
449
+ temp_file = Tempfile.new(["emoticon", ".gif"])
450
+ while data = r.readpartial
451
+ temp_file.write data
452
+ end
453
+ temp_file.close
454
+
455
+ temp_file
456
+ end
457
+
458
+ # 创建群组
459
+ #
460
+ # @param [Array<String>] users
461
+ # @return [Hash<Object, Object>]
462
+ def create_group(*users)
463
+ url = "#{store(:index_url)}/webwxcreatechatroom?r=#{timestamp}"
464
+ params = params_base_request.merge({
465
+ "Topic" => "",
466
+ "MemberCount" => users.size,
467
+ "MemberList" => users.map { |u| { "UserName" => u } }
468
+ })
469
+
470
+ r = @session.post(url, json: params)
471
+ r.parse(:json)
472
+ end
473
+
474
+ # 登出
475
+ #
476
+ # @return [void]
477
+ def logout
478
+ url = "#{store(:index_url)}/webwxlogout"
479
+ params = {
480
+ "redirect" => 1,
481
+ "type" => 1,
482
+ "skey" => store(:skey)
483
+ }
484
+
485
+ @session.get(url, params: params)
486
+
487
+ @bot.logger.info "用户 [#{@bot.profile.nickname}] 登出成功!"
488
+ clone!
489
+ end
490
+
491
+ # 获取登录状态
492
+ #
493
+ # @return [Boolean]
494
+ def logged?
495
+ @is_logged
496
+ end
497
+
498
+ # 获取是否在线(存活)
499
+ #
500
+ # @return [Boolean]
501
+ def alive?
502
+ @is_alive
503
+ end
504
+
505
+ private
506
+
507
+ # 保存和获取存储数据
508
+ #
509
+ # @return [Object] 获取数据返回该变量对应的值类型
510
+ # @return [void] 保存数据时无返回值
511
+ def store(*args)
512
+ return @store[args[0].to_sym] = args[1] if args.size == 2
513
+
514
+ if args.size == 1
515
+ obj = args[0]
516
+ return @store[obj.to_sym] if obj.is_a?(String) || obj.is_a?(Symbol)
517
+
518
+ obj.each do |key, value|
519
+ @store[key.to_sym] = value
520
+ end if obj.is_a?(Hash)
521
+ end
522
+ end
523
+
524
+ # 生成 13 位 unix 时间戳
525
+ # @return [String]
526
+ def timestamp
527
+ Time.now.strftime("%s%3N")
528
+ end
529
+
530
+ # 匹配对于的微信服务器
531
+ #
532
+ # @param [Hash<String, String>] servers
533
+ # @return [void]
534
+ def update_servers(servers)
535
+ server_scheme = "https"
536
+ server_path = "/cgi-bin/mmwebwx-bin"
537
+ servers.each do |name, host|
538
+ store("#{name}_url", "#{server_scheme}://#{host}#{server_path}")
539
+ end
540
+ end
541
+
542
+ # 微信接口请求参数 BaseRequest
543
+ #
544
+ # @return [Hash<String, String>]
545
+ def params_base_request
546
+ return @base_request if @base_request
547
+
548
+ @base_request = {
549
+ "BaseRequest" => {
550
+ "Skey" => store(:skey),
551
+ "Sid" => store(:sid),
552
+ "Uin" => store(:uin),
553
+ "DeviceID" => store(:pass_ticket),
554
+ }
555
+ }
556
+ end
557
+
558
+ # 微信接口参数序列后的 SyncKey
559
+ #
560
+ # @return [void]
561
+ def params_sync_key
562
+ store(:sync_key)["List"].map {|i| i.values.join("_") }.join("|")
563
+ end
564
+
565
+ # 初始化变量
566
+ #
567
+ # @return [void]
568
+ def clone!
569
+ @session = HTTP::Session.new(@bot)
570
+ @is_logged = @is_alive = false
571
+ @store = {}
572
+ end
573
+ end
574
+ end