wechat 0.2.0

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,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