wechat-bot2 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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)
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
@@ -0,0 +1,70 @@
1
+ require 'ostruct'
2
+
3
+ module WeChat::Bot
4
+ class Configuration < OpenStruct
5
+ # 默认配置
6
+ #
7
+ # @return [Hash]
8
+ def self.default_config
9
+ {
10
+ # Bot Configurations
11
+ verbose: false,
12
+ fireman: 'filehelper',
13
+
14
+ # WeChat Configurations
15
+ app_id: 'wx782c26e4c19acffb',
16
+ auth_url: 'https://login.weixin.qq.com',
17
+ servers: [
18
+ {
19
+ index: 'wx.qq.com',
20
+ file: 'file.wx.qq.com',
21
+ push: 'webpush.wx.qq.com',
22
+ },
23
+ {
24
+ index: 'wx2.qq.com',
25
+ file: 'file.wx2.qq.com',
26
+ push: 'webpush.wx2.qq.com',
27
+ },
28
+ {
29
+ index: 'wx8.qq.com',
30
+ file: 'file.wx8.qq.com',
31
+ push: 'webpush.wx8.qq.com',
32
+ },
33
+ {
34
+ index: 'wechat.com',
35
+ file: 'file.web.wechat.com',
36
+ push: 'webpush.web.wechat.com',
37
+ },
38
+ {
39
+ index: 'web2.wechat.com',
40
+ file: 'file.web2.wechat.com',
41
+ push: 'webpush.web2.wechat.com',
42
+ },
43
+ ],
44
+ cookies: 'wechat-bot-cookies.txt',
45
+ special_users: [
46
+ 'newsapp', 'filehelper', 'weibo', 'qqmail',
47
+ 'fmessage', 'tmessage', 'qmessage', 'qqsync',
48
+ 'floatbottle', 'lbsapp', 'shakeapp', 'medianote',
49
+ 'qqfriend', 'readerapp', 'blogapp', 'facebookapp',
50
+ 'masssendapp', 'meishiapp', 'feedsapp', 'voip',
51
+ 'blogappweixin', 'brandsessionholder', 'weixin',
52
+ 'weixinreminder', 'officialaccounts', 'wxitil',
53
+ 'notification_messages', 'wxid_novlwrv3lqwv11',
54
+ 'gh_22b87fa7cb3c', 'userexperience_alarm',
55
+ ],
56
+ user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.86 Safari/537.36',
57
+ }
58
+ end
59
+
60
+ def initialize(defaults = nil)
61
+ defaults ||= self.class.default_config
62
+ super(defaults)
63
+ end
64
+
65
+ # @return [Hash]
66
+ def to_h
67
+ @table.clone
68
+ end
69
+ end
70
+ end