wechat 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +405 -0
- data/Rakefile +29 -0
- data/bin/wechat +172 -0
- data/lib/wechat.rb +104 -0
- data/lib/wechat/access_token.rb +41 -0
- data/lib/wechat/api.rb +85 -0
- data/lib/wechat/cipher.rb +72 -0
- data/lib/wechat/client.rb +79 -0
- data/lib/wechat/corp_api.rb +66 -0
- data/lib/wechat/jsapi_ticket.rb +86 -0
- data/lib/wechat/message.rb +176 -0
- data/lib/wechat/responder.rb +177 -0
- metadata +115 -0
data/bin/wechat
ADDED
@@ -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
|
data/lib/wechat.rb
ADDED
@@ -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
|
data/lib/wechat/api.rb
ADDED
@@ -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
|