wechat 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ lib = File.expand_path(File.dirname(__FILE__) + '/../lib')
4
+ $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
5
+
6
+ require 'thor'
7
+ require 'wechat'
8
+ require 'json'
9
+ require 'active_support/dependencies/autoload'
10
+ require 'active_support/core_ext'
11
+ require 'active_support/json'
12
+ require 'fileutils'
13
+ require 'yaml'
14
+
15
+ class App < Thor
16
+ class Helper
17
+ def self.with(options)
18
+ config = loading_config
19
+
20
+ appid = config['appid']
21
+ secret = config['secret']
22
+ corpid = config['corpid']
23
+ corpsecret = config['corpsecret']
24
+ token_file = options[:toke_file] || config['access_token'] || '/var/tmp/wechat_access_token'
25
+ agentid = config['agentid']
26
+
27
+ if appid.present? && secret.present? && token_file.present?
28
+ Wechat::Api.new(appid, secret, token_file)
29
+ elsif corpid.present? && corpsecret.present? && token_file.present?
30
+ Wechat::CorpApi.new(corpid, corpsecret, token_file, agentid)
31
+ else
32
+ puts <<-HELP
33
+ Need create ~/.wechat.yml with wechat appid and secret
34
+ or running at rails root folder so wechat can read config/wechat.yml
35
+ HELP
36
+ exit 1
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def self.loading_config
43
+ config = {}
44
+
45
+ rails_config_file = File.join(Dir.getwd, 'config/wechat.yml')
46
+ home_config_file = File.join(Dir.home, '.wechat.yml')
47
+
48
+ if File.exist?(rails_config_file)
49
+ config = YAML.load(ERB.new(File.new(rails_config_file).read).result)['default']
50
+ if config.present? && (config['appid'] || config['corpid'])
51
+ puts 'Using rails project config/wechat.yml default setting...'
52
+ else
53
+ config = {}
54
+ end
55
+ end
56
+
57
+ if config.blank? && File.exist?(home_config_file)
58
+ config = YAML.load ERB.new(File.read(home_config_file)).result
59
+ end
60
+ config
61
+ end
62
+ end
63
+
64
+ package_name 'Wechat'
65
+ option :toke_file, aliases: '-t', desc: 'File to store access token'
66
+
67
+ desc 'users', '关注者列表'
68
+ def users
69
+ puts Helper.with(options).users
70
+ end
71
+
72
+ desc 'user [OPEN_ID]', '查找关注者'
73
+ def user(open_id)
74
+ puts Helper.with(options).user(open_id)
75
+ end
76
+
77
+ desc 'menu', '当前菜单'
78
+ def menu
79
+ puts Helper.with(options).menu
80
+ end
81
+
82
+ desc 'menu_delete', '删除菜单'
83
+ def menu_delete
84
+ puts 'Menu deleted' if Helper.with(options).menu_delete
85
+ end
86
+
87
+ desc 'menu_create [MENU_YAML_PATH]', '创建菜单'
88
+ def menu_create(menu_yaml_path)
89
+ menu = YAML.load(File.new(menu_yaml_path).read)
90
+ puts 'Menu created' if Helper.with(options).menu_create(menu)
91
+ end
92
+
93
+ desc 'media [MEDIA_ID, PATH]', '媒体下载'
94
+ def media(media_id, path)
95
+ tmp_file = Helper.with(options).media(media_id)
96
+ FileUtils.mv(tmp_file.path, path)
97
+ puts 'File downloaded'
98
+ end
99
+
100
+ desc 'media_create [MEDIA_TYPE, PATH]', '媒体上传'
101
+ def media_create(type, path)
102
+ file = File.new(path)
103
+ puts Helper.with(options).media_create(type, file)
104
+ end
105
+
106
+ desc 'message_send [OPENID, TEXT_MESSAGE]', '发送文字消息(仅企业号)'
107
+ def message_send(openid, text_message)
108
+ puts Helper.with(options).message_send Wechat::Message.to(openid).text(text_message)
109
+ end
110
+
111
+ desc 'custom_text [OPENID, TEXT_MESSAGE]', '发送文字客服消息'
112
+ def custom_text(openid, text_message)
113
+ puts Helper.with(options).custom_message_send Wechat::Message.to(openid).text(text_message)
114
+ end
115
+
116
+ desc 'custom_image [OPENID, IMAGE_PATH]', '发送图片客服消息'
117
+ def custom_image(openid, image_path)
118
+ file = File.new(image_path)
119
+ api = Helper.with(options)
120
+
121
+ media_id = api.media_create('image', file)['media_id']
122
+ puts api.custom_message_send Wechat::Message.to(openid).image(media_id)
123
+ end
124
+
125
+ desc 'custom_voice [OPENID, VOICE_PATH]', '发送语音客服消息'
126
+ def custom_voice(openid, voice_path)
127
+ file = File.new(voice_path)
128
+ api = Helper.with(options)
129
+
130
+ media_id = api.media_create('voice', file)['media_id']
131
+ puts api.custom_message_send Wechat::Message.to(openid).voice(media_id)
132
+ end
133
+
134
+ desc 'custom_video [OPENID, VIDEO_PATH]', '发送视频客服消息'
135
+ method_option :title, aliases: '-h', desc: '视频标题'
136
+ method_option :description, aliases: '-d', desc: '视频描述'
137
+ def custom_video(openid, video_path)
138
+ file = File.new(video_path)
139
+ api = Helper.with(options)
140
+
141
+ api_opts = options.slice(:title, :description)
142
+ media_id = api.media_create('video', file)['media_id']
143
+ puts api.custom_message_send Wechat::Message.to(openid).video(media_id, api_opts)
144
+ end
145
+
146
+ desc 'custom_music [OPENID, THUMBNAIL_PATH, MUSIC_URL]', '发送音乐客服消息'
147
+ method_option :title, aliases: '-h', desc: '音乐标题'
148
+ method_option :description, aliases: '-d', desc: '音乐描述'
149
+ method_option :HQ_music_url, aliases: '-u', desc: '高质量音乐URL链接'
150
+ def custom_music(openid, thumbnail_path, music_url)
151
+ file = File.new(thumbnail_path)
152
+ api = Helper.with(options)
153
+
154
+ api_opts = options.slice(:title, :description, :HQ_music_url)
155
+ thumb_media_id = api.media_create('thumb', file)['thumb_media_id']
156
+ puts api.custom_message_send Wechat::Message.to(openid).music(thumb_media_id, music_url, api_opts)
157
+ end
158
+
159
+ desc 'custom_news [OPENID, NEWS_YAML_PATH]', '发送图文客服消息'
160
+ def custom_news(openid, news_yaml_path)
161
+ articles = YAML.load(File.new(news_yaml_path).read)
162
+ puts Helper.with(options).custom_message_send Wechat::Message.to(openid).news(articles['articles'])
163
+ end
164
+
165
+ desc 'template_message [OPENID, TEMPLATE_YAML_PATH]', '模板消息接口'
166
+ def template_message(openid, template_yaml_path)
167
+ template = YAML.load(File.new(template_yaml_path).read)
168
+ puts Helper.with(options).template_message_send Wechat::Message.to(openid).template(template['template'])
169
+ end
170
+ end
171
+
172
+ App.start
@@ -0,0 +1,104 @@
1
+ require 'wechat/api'
2
+ require 'wechat/corp_api'
3
+
4
+ module Wechat
5
+ autoload :Message, 'wechat/message'
6
+ autoload :Responder, 'wechat/responder'
7
+ autoload :Cipher, 'wechat/cipher'
8
+
9
+ class AccessTokenExpiredError < StandardError; end
10
+ class ResponseError < StandardError
11
+ attr_reader :error_code
12
+ def initialize(errcode, errmsg)
13
+ @error_code = errcode
14
+ super "#{errmsg}(#{error_code})"
15
+ end
16
+ end
17
+
18
+ attr_reader :config
19
+
20
+ def self.config
21
+ @config ||= begin
22
+ if defined? Rails
23
+ config_file = Rails.root.join('config/wechat.yml')
24
+ config = YAML.load(ERB.new(File.new(config_file).read).result)[Rails.env] if File.exist?(config_file)
25
+ end
26
+
27
+ config ||= { appid: ENV['WECHAT_APPID'],
28
+ secret: ENV['WECHAT_SECRET'],
29
+ corpid: ENV['WECHAT_CORPID'],
30
+ corpsecret: ENV['WECHAT_CORPSECRET'],
31
+ agentid: ENV['WECHAT_AGENTID'],
32
+ token: ENV['WECHAT_TOKEN'],
33
+ access_token: ENV['WECHAT_ACCESS_TOKEN'],
34
+ encrypt_mode: ENV['WECHAT_ENCRYPT_MODE'],
35
+ encoding_aes_key: ENV['WECHAT_ENCODING_AES_KEY'] }
36
+ config.symbolize_keys!
37
+ config[:access_token] ||= Rails.root.join('tmp/access_token').to_s
38
+ config[:jsapi_ticket] ||= Rails.root.join('tmp/jsapi_ticket').to_s
39
+ OpenStruct.new(config)
40
+ end
41
+ end
42
+
43
+ def self.api
44
+ if config.corpid.present?
45
+ @api ||= Wechat::CorpApi.new(config.corpid, config.corpsecret, config.access_token, config.agentid)
46
+ else
47
+ @api ||= Wechat::Api.new(config.appid, config.secret, config.access_token, config.jsapi_ticket)
48
+ end
49
+ end
50
+ end
51
+
52
+ if defined? ActionController::Base
53
+ class ActionController::Base
54
+ def self.wechat_responder(opts = {})
55
+ send(:include, Wechat::Responder)
56
+ if opts.empty?
57
+ self.corpid = Wechat.config.corpid
58
+ self.wechat = Wechat.api
59
+ self.agentid = Wechat.config.agentid
60
+ self.token = Wechat.config.token
61
+ self.encrypt_mode = Wechat.config.encrypt_mode
62
+ self.encoding_aes_key = Wechat.config.encoding_aes_key
63
+ else
64
+ self.corpid = opts[:corpid]
65
+ if corpid.present?
66
+ self.wechat = Wechat::CorpApi.new(opts[:corpid], opts[:corpsecret], opts[:access_token], opts[:agentid])
67
+ else
68
+ self.wechat = Wechat::Api.new(opts[:appid], opts[:secret], opts[:access_token], opts[:jsapi_ticket])
69
+ end
70
+ self.agentid = opts[:agentid]
71
+ self.token = opts[:token]
72
+ self.encrypt_mode = opts[:encrypt_mode]
73
+ self.encoding_aes_key = opts[:encoding_aes_key]
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ if defined? ActionController::API
80
+ class ActionController::API
81
+ def self.wechat_responder(opts = {})
82
+ send(:include, Wechat::Responder)
83
+ if opts.empty?
84
+ self.corpid = Wechat.config.corpid
85
+ self.wechat = Wechat.api
86
+ self.agentid = Wechat.config.agentid
87
+ self.token = Wechat.config.token
88
+ self.encrypt_mode = Wechat.config.encrypt_mode
89
+ self.encoding_aes_key = Wechat.config.encoding_aes_key
90
+ else
91
+ self.corpid = opts[:corpid]
92
+ if corpid.present?
93
+ self.wechat = Wechat::CorpApi.new(opts[:corpid], opts[:corpsecret], opts[:access_token], opts[:agentid])
94
+ else
95
+ self.wechat = Wechat::Api.new(opts[:appid], opts[:secret], opts[:access_token], opts[:jsapi_ticket])
96
+ end
97
+ self.agentid = opts[:agentid]
98
+ self.token = opts[:token]
99
+ self.encrypt_mode = opts[:encrypt_mode]
100
+ self.encoding_aes_key = opts[:encoding_aes_key]
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,41 @@
1
+ module Wechat
2
+ class AccessToken
3
+ attr_reader :client, :appid, :secret, :token_file, :token_data
4
+
5
+ def initialize(client, appid, secret, token_file)
6
+ @appid = appid
7
+ @secret = secret
8
+ @client = client
9
+ @token_file = token_file
10
+ end
11
+
12
+ def token
13
+ begin
14
+ @token_data ||= JSON.parse(File.read(token_file))
15
+ created_at = token_data['created_at'].to_i
16
+ expires_in = token_data['expires_in'].to_i
17
+ if Time.now.to_i - created_at >= expires_in - 3 * 60
18
+ raise 'token_data may be expired'
19
+ end
20
+ rescue
21
+ refresh
22
+ end
23
+ valid_token(@token_data)
24
+ end
25
+
26
+ def refresh
27
+ data = client.get('token', params: { grant_type: 'client_credential', appid: appid, secret: secret })
28
+ data.merge!(created_at: Time.now.to_i)
29
+ File.open(token_file, 'w') { |f| f.write(data.to_json) } if valid_token(data)
30
+ @token_data = data
31
+ end
32
+
33
+ private
34
+
35
+ def valid_token(token_data)
36
+ access_token = token_data['access_token']
37
+ raise "Response didn't have access_token" if access_token.blank?
38
+ access_token
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,85 @@
1
+ require 'wechat/client'
2
+ require 'wechat/access_token'
3
+ require 'wechat/jsapi_ticket'
4
+
5
+ class Wechat::Api
6
+ attr_reader :access_token, :client, :jsapi_ticket
7
+
8
+ API_BASE = 'https://api.weixin.qq.com/cgi-bin/'
9
+ FILE_BASE = 'http://file.api.weixin.qq.com/cgi-bin/'
10
+ OAUTH2_BASE = 'https://api.weixin.qq.com/sns/oauth2/'
11
+
12
+ def initialize(appid, secret, token_file, jsapi_ticket_file = '/var/tmp/wechat_jsapi_ticket')
13
+ @client = Wechat::Client.new(API_BASE)
14
+ @access_token = Wechat::AccessToken.new(@client, appid, secret, token_file)
15
+ @jsapi_ticket = Wechat::JsapiTicket.new(@client, @access_token, jsapi_ticket_file)
16
+ end
17
+
18
+ def users(nextid = nil)
19
+ params = { params: { next_openid: nextid } } if nextid.present?
20
+ get('user/get', params || {})
21
+ end
22
+
23
+ def user(openid)
24
+ get('user/info', params: { openid: openid })
25
+ end
26
+
27
+ def menu
28
+ get('menu/get')
29
+ end
30
+
31
+ def menu_delete
32
+ get('menu/delete')
33
+ end
34
+
35
+ def menu_create(menu)
36
+ # 微信不接受7bit escaped json(eg \uxxxx), 中文必须UTF-8编码, 这可能是个安全漏洞
37
+ post('menu/create', JSON.generate(menu))
38
+ end
39
+
40
+ def media(media_id)
41
+ get 'media/get', params: { media_id: media_id }, base: FILE_BASE, as: :file
42
+ end
43
+
44
+ def media_create(type, file)
45
+ post 'media/upload', { upload: { media: file } }, params: { type: type }, base: FILE_BASE
46
+ end
47
+
48
+ def custom_message_send(message)
49
+ post 'message/custom/send', message.to_json, content_type: :json
50
+ end
51
+
52
+ def template_message_send(message)
53
+ post 'message/template/send', message.to_json, content_type: :json
54
+ end
55
+
56
+ # http://mp.weixin.qq.com/wiki/17/c0f37d5704f0b64713d5d2c37b468d75.html
57
+ # 第二步:通过code换取网页授权access_token
58
+ def web_access_token(code)
59
+ params = {
60
+ appid: access_token.appid,
61
+ secret: access_token.secret,
62
+ code: code,
63
+ grant_type: 'authorization_code'
64
+ }
65
+ get 'access_token', params: params, base: OAUTH2_BASE
66
+ end
67
+
68
+ protected
69
+
70
+ def get(path, headers = {})
71
+ with_access_token(headers[:params]) { |params| client.get path, headers.merge(params: params) }
72
+ end
73
+
74
+ def post(path, payload, headers = {})
75
+ with_access_token(headers[:params]) { |params| client.post path, payload, headers.merge(params: params) }
76
+ end
77
+
78
+ def with_access_token(params = {}, tries = 2)
79
+ params ||= {}
80
+ yield(params.merge(access_token: access_token.token))
81
+ rescue Wechat::AccessTokenExpiredError
82
+ access_token.refresh
83
+ retry unless (tries -= 1).zero?
84
+ end
85
+ end
@@ -0,0 +1,72 @@
1
+ require 'openssl/cipher'
2
+ require 'securerandom'
3
+ require 'base64'
4
+
5
+ module Wechat
6
+ module Cipher
7
+ extend ActiveSupport::Concern
8
+
9
+ BLOCK_SIZE = 32
10
+ CIPHER = 'AES-256-CBC'
11
+
12
+ def encrypt(plain, encoding_aes_key)
13
+ cipher = OpenSSL::Cipher.new(CIPHER)
14
+ cipher.encrypt
15
+
16
+ cipher.padding = 0
17
+ key_data = Base64.decode64(encoding_aes_key)
18
+ cipher.key = key_data
19
+ cipher.iv = key_data[0..16]
20
+
21
+ cipher.update(encode_padding(plain)) + cipher.final
22
+ end
23
+
24
+ def decrypt(msg, encoding_aes_key)
25
+ cipher = OpenSSL::Cipher.new(CIPHER)
26
+ cipher.decrypt
27
+
28
+ cipher.padding = 0
29
+ key_data = Base64.decode64(encoding_aes_key)
30
+ cipher.key = key_data
31
+ cipher.iv = key_data[0..16]
32
+
33
+ plain = cipher.update(msg) + cipher.final
34
+ decode_padding(plain)
35
+ end
36
+
37
+ # app_id or corp_id
38
+ def pack(content, app_id)
39
+ random = SecureRandom.hex(8)
40
+ text = content.force_encoding('ASCII-8BIT')
41
+ msg_len = [text.length].pack('N')
42
+
43
+ encode_padding("#{random}#{msg_len}#{text}#{app_id}")
44
+ end
45
+
46
+ def unpack(msg)
47
+ msg = decode_padding(msg)
48
+ msg_len = msg[16, 4].reverse.unpack('V')[0]
49
+ content = msg[20, msg_len]
50
+ app_id = msg[(20 + msg_len)..-1]
51
+
52
+ [content, app_id]
53
+ end
54
+
55
+ private
56
+
57
+ def encode_padding(data)
58
+ length = data.bytes.length
59
+ amount_to_pad = BLOCK_SIZE - (length % BLOCK_SIZE)
60
+ amount_to_pad = BLOCK_SIZE if amount_to_pad == 0
61
+ padding = ([amount_to_pad].pack('c') * amount_to_pad)
62
+ data + padding
63
+ end
64
+
65
+ def decode_padding(plain)
66
+ pad = plain.bytes[-1]
67
+ # no padding
68
+ pad = 0 if pad < 1 || pad > BLOCK_SIZE
69
+ plain[0...(plain.length - pad)]
70
+ end
71
+ end
72
+ end