wechat 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,79 @@
1
+ require 'rest_client'
2
+
3
+ module Wechat
4
+ class Client
5
+ attr_reader :base
6
+
7
+ def initialize(base)
8
+ @base = base
9
+ end
10
+
11
+ def get(path, header = {}, verify_ssl = true)
12
+ request(path, header) do |url, header|
13
+ if verify_ssl
14
+ RestClient.get(url, header)
15
+ else
16
+ RestClient::Request.execute(url: url, method: :get, headers: header, verify_ssl: OpenSSL::SSL::VERIFY_NONE)
17
+ end
18
+ end
19
+ end
20
+
21
+ def post(path, payload, header = {}, verify_ssl = true)
22
+ request(path, header) do |url, header|
23
+ if verify_ssl
24
+ RestClient.post(url, payload, header)
25
+ else
26
+ RestClient::Request.execute(url: url, method: :post, payload: payload, headers: header, verify_ssl: OpenSSL::SSL::VERIFY_NONE)
27
+ end
28
+ end
29
+ end
30
+
31
+ def request(path, header = {}, &block)
32
+ url = "#{header.delete(:base) || self.base}#{path}"
33
+ as = header.delete(:as)
34
+ header.merge!(:accept => :json)
35
+ response = yield(url, header)
36
+
37
+ raise "Request not OK, response code #{response.code}" if response.code != 200
38
+ parse_response(response, as || :json) do |parse_as, data|
39
+ break data unless parse_as == :json && data['errcode'].present?
40
+
41
+ case data['errcode']
42
+ when 0 # for request didn't expect results
43
+ data
44
+ when 42_001, 40_014 # 42001: access_token超时, 40014:不合法的access_token
45
+ raise AccessTokenExpiredError
46
+ else
47
+ raise ResponseError.new(data['errcode'], data['errmsg'])
48
+ end
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def parse_response(response, as)
55
+ content_type = response.headers[:content_type]
56
+ parse_as = {
57
+ /^application\/json/ => :json,
58
+ /^image\/.*/ => :file
59
+ }.inject([]){ |memo, match| memo << match[1] if content_type =~ match[0]; memo }.first || as || :text
60
+
61
+ case parse_as
62
+ when :file
63
+ file = Tempfile.new('tmp')
64
+ file.binmode
65
+ file.write(response.body)
66
+ file.close
67
+ data = file
68
+
69
+ when :json
70
+ data = JSON.parse(response.body.gsub /[\u0000-\u001f]+/, '')
71
+
72
+ else
73
+ data = response.body
74
+ end
75
+
76
+ yield(parse_as, data)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,66 @@
1
+ require 'wechat/client'
2
+ require 'wechat/access_token'
3
+
4
+ class Wechat::CorpAccessToken < Wechat::AccessToken
5
+ def refresh
6
+ data = client.get('gettoken', { params: { corpid: appid, corpsecret: secret }}, false)
7
+ data.merge!(created_at: Time.now.to_i)
8
+ File.open(token_file, 'w') { |f| f.write(data.to_json) } if valid_token(data)
9
+ @token_data = data
10
+ end
11
+ end
12
+
13
+ class Wechat::CorpApi
14
+ attr_reader :access_token, :client, :agentid
15
+
16
+ API_BASE = 'https://qyapi.weixin.qq.com/cgi-bin/'
17
+
18
+ def initialize(appid, secret, token_file, agentid)
19
+ @client = Wechat::Client.new(API_BASE)
20
+ @access_token = Wechat::CorpAccessToken.new(@client, appid, secret, token_file)
21
+ @agentid = agentid
22
+ end
23
+
24
+ def user(userid)
25
+ get('user/get', params: { userid: userid })
26
+ end
27
+
28
+ def menu
29
+ get('menu/get', params: { agentid: agentid })
30
+ end
31
+
32
+ def menu_delete
33
+ get('menu/delete', params: { agentid: agentid })
34
+ end
35
+
36
+ def menu_create(menu)
37
+ # 微信不接受7bit escaped json(eg \uxxxx), 中文必须UTF-8编码, 这可能是个安全漏洞
38
+ post 'menu/create', JSON.generate(menu), { params: { agentid: agentid } }
39
+ end
40
+
41
+ def message_send(message)
42
+ post 'message/send', message.agent_id(agentid).to_json, content_type: :json
43
+ end
44
+
45
+ protected
46
+
47
+ def get(path, headers = {})
48
+ with_access_token(headers[:params]) do |params|
49
+ client.get path, headers.merge(params: params), false
50
+ end
51
+ end
52
+
53
+ def post(path, payload, headers = {})
54
+ with_access_token(headers[:params]) do |params|
55
+ client.post path, payload, headers.merge(params: params), false
56
+ end
57
+ end
58
+
59
+ def with_access_token(params = {}, tries = 2)
60
+ params ||= {}
61
+ yield(params.merge(access_token: access_token.token))
62
+ rescue Wechat::AccessTokenExpiredError
63
+ access_token.refresh
64
+ retry unless (tries -= 1).zero?
65
+ end
66
+ end
@@ -0,0 +1,86 @@
1
+ require 'digest/sha1'
2
+
3
+ module Wechat
4
+ class JsapiTicket
5
+ attr_reader :client, :access_token, :jsapi_ticket_file, :jsapi_ticket_data
6
+
7
+ def initialize(client, access_token, jsapi_ticket_file)
8
+ @client = client
9
+ @access_token = access_token
10
+ @jsapi_ticket_file = jsapi_ticket_file
11
+ end
12
+
13
+ # 获取微信 jssdk 签名所需的 jsapi_ticket, 返回具有如下结构的 hash:
14
+ # {
15
+ # "errcode":0,
16
+ # "errmsg":"ok",
17
+ # "ticket":"bxLdikRXVbTPdHSM05e5u5sUoXNKd8-41ZO3MhKoyN5OfkWITDGgnr2fwJ0m9E8NYzWKVZvdVtaUgWvsdshFKA",
18
+ # "expires_in":7200
19
+ # }
20
+ def ticket
21
+ begin
22
+ @jsapi_ticket_data ||= JSON.parse(File.read(jsapi_ticket_file))
23
+ created_at = jsapi_ticket_data['created_at'].to_i
24
+ expires_in = jsapi_ticket_data['expires_in'].to_i
25
+ if Time.now.to_i - created_at >= expires_in - 3 * 60
26
+ raise 'jsapi_ticket may be expired'
27
+ end
28
+ rescue
29
+ refresh
30
+ end
31
+ valid_ticket(@jsapi_ticket_data)
32
+ end
33
+
34
+ # 刷新 jsapi_ticket
35
+ def refresh
36
+ data = client.get('ticket/getticket', params: { access_token: access_token.token, type: 'jsapi' })
37
+ data.merge!(created_at: Time.now.to_i)
38
+ File.open(jsapi_ticket_file, 'w') { |f| f.write(data.to_json) } if valid_ticket(data)
39
+ @jsapi_ticket_data = data
40
+ end
41
+
42
+ # 获取 jssdk 签名及注册所需其他参数, 返回具有如下结构的 hash:
43
+ # params = {
44
+ # noncestr: noncestr,
45
+ # timestamp: timestamp,
46
+ # jsapi_ticket: ticket,
47
+ # url: url,
48
+ # signature: signature
49
+ # }
50
+ def signature(url)
51
+ timestamp = Time.now.to_i
52
+ noncestr = generate_noncestr
53
+ params = {
54
+ noncestr: noncestr,
55
+ timestamp: timestamp,
56
+ jsapi_ticket: ticket,
57
+ url: url
58
+ }
59
+ pairs = params.keys.sort.map do |key|
60
+ "#{key}=#{params[key]}"
61
+ end
62
+ result = Digest::SHA1.hexdigest pairs.join('&')
63
+ params.merge(signature: result)
64
+ end
65
+
66
+ private
67
+
68
+ def valid_ticket(jsapi_ticket_data)
69
+ ticket = jsapi_ticket_data['ticket'] || jsapi_ticket_data[:ticket]
70
+ raise "Response didn't have ticket" if ticket.blank?
71
+ ticket
72
+ end
73
+
74
+ # 生成随机字符串
75
+ # @param Integer length 长度, 默认为16
76
+ # @return String
77
+ def generate_noncestr(length = 16)
78
+ chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
79
+ str = ''
80
+ 1.upto(length) do |i|
81
+ str += chars[rand(chars.length)]
82
+ end
83
+ str
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,176 @@
1
+ module Wechat
2
+ class Message
3
+ class << self
4
+ def from_hash(message_hash)
5
+ new(message_hash)
6
+ end
7
+
8
+ def to(to_user)
9
+ new(ToUserName: to_user, CreateTime: Time.now.to_i)
10
+ end
11
+ end
12
+
13
+ class ArticleBuilder
14
+ attr_reader :items
15
+ delegate :count, to: :items
16
+ def initialize
17
+ @items = []
18
+ end
19
+
20
+ def item(title: 'title', description: nil, pic_url: nil, url: nil)
21
+ items << { Title: title, Description: description, PicUrl: pic_url, Url: url }.reject { |_k, v| v.nil? }
22
+ end
23
+ end
24
+
25
+ attr_reader :message_hash
26
+
27
+ def initialize(message_hash)
28
+ @message_hash = message_hash || {}
29
+ end
30
+
31
+ def [](key)
32
+ message_hash[key]
33
+ end
34
+
35
+ def reply
36
+ Message.new(
37
+ ToUserName: message_hash[:FromUserName],
38
+ FromUserName: message_hash[:ToUserName],
39
+ CreateTime: Time.now.to_i
40
+ )
41
+ end
42
+
43
+ def as(type)
44
+ case type
45
+ when :text
46
+ message_hash[:Content]
47
+
48
+ when :image, :voice, :video
49
+ Wechat.api.media(message_hash[:MediaId])
50
+
51
+ when :location
52
+ message_hash.slice(:Location_X, :Location_Y, :Scale, :Label).inject({}) { |results, value|
53
+ results[value[0].to_s.underscore.to_sym] = value[1]; results }
54
+ else
55
+ raise "Don't know how to parse message as #{type}"
56
+ end
57
+ end
58
+
59
+ def to(openid)
60
+ update(ToUserName: openid)
61
+ end
62
+
63
+ def agent_id(agentid)
64
+ update(AgentId: agentid)
65
+ end
66
+
67
+ def text(content)
68
+ update(MsgType: 'text', Content: content)
69
+ end
70
+
71
+ def image(media_id)
72
+ update(MsgType: 'image', Image: { MediaId: media_id })
73
+ end
74
+
75
+ def voice(media_id)
76
+ update(MsgType: 'voice', Voice: { MediaId: media_id })
77
+ end
78
+
79
+ def video(media_id, opts = {})
80
+ video_fields = camelize_hash_keys({ media_id: media_id }.merge(opts.slice(:title, :description)))
81
+ update(MsgType: 'video', Video: video_fields)
82
+ end
83
+
84
+ def music(thumb_media_id, music_url, opts = {})
85
+ music_fields = camelize_hash_keys(opts.slice(:title, :description, :HQ_music_url).merge(music_url: music_url, thumb_media_id: thumb_media_id))
86
+ update(MsgType: 'music', Music: music_fields)
87
+ end
88
+
89
+ def news(collection, &block)
90
+ if block_given?
91
+ article = ArticleBuilder.new
92
+ collection.each { |item| yield(article, item) }
93
+ items = article.items
94
+ else
95
+ items = collection.collect do |item|
96
+ camelize_hash_keys(item.symbolize_keys.slice(:title, :description, :pic_url, :url).reject { |_k, v| v.nil? })
97
+ end
98
+ end
99
+
100
+ update(MsgType: 'news', ArticleCount: items.count,
101
+ Articles: items.collect { |item| camelize_hash_keys(item) })
102
+ end
103
+
104
+ def template(opts = {})
105
+ template_fields = camelize_hash_keys(opts.symbolize_keys.slice(:template_id, :topcolor, :url, :data))
106
+ update(MsgType: 'template', Template: template_fields)
107
+ end
108
+
109
+ def to_xml
110
+ message_hash.to_xml(root: 'xml', children: 'item', skip_instruct: true, skip_types: true)
111
+ end
112
+
113
+ TO_JSON_KEY_MAP = {
114
+ 'ToUserName' => 'touser',
115
+ 'MediaId' => 'media_id',
116
+ 'ThumbMediaId' => 'thumb_media_id',
117
+ 'TemplateId' => 'template_id'
118
+ }
119
+
120
+ TO_JSON_ALLOWED = %w(touser msgtype content image voice video music news articles template agentid)
121
+
122
+ def to_json
123
+ json_hash = deep_recursive(message_hash) do |key, value|
124
+ key = key.to_s
125
+ [(TO_JSON_KEY_MAP[key] || key.downcase), value]
126
+ end
127
+
128
+ json_hash = json_hash.select { |k, _v| TO_JSON_ALLOWED.include? k }
129
+ case json_hash['msgtype']
130
+ when 'text'
131
+ json_hash['text'] = { 'content' => json_hash.delete('content') }
132
+ when 'news'
133
+ json_hash['news'] = { 'articles' => json_hash.delete('articles') }
134
+ when 'template'
135
+ json_hash.merge! json_hash['template']
136
+ end
137
+ JSON.generate(json_hash)
138
+ end
139
+
140
+ def save_to!(model_class)
141
+ model = model_class.new(underscore_hash_keys(message_hash))
142
+ model.save!
143
+ self
144
+ end
145
+
146
+ private
147
+
148
+ def camelize_hash_keys(hash)
149
+ deep_recursive(hash) { |key, value| [key.to_s.camelize.to_sym, value] }
150
+ end
151
+
152
+ def underscore_hash_keys(hash)
153
+ deep_recursive(hash) { |key, value| [key.to_s.underscore.to_sym, value] }
154
+ end
155
+
156
+ def update(fields = {})
157
+ message_hash.merge!(fields)
158
+ self
159
+ end
160
+
161
+ def deep_recursive(hash, &block)
162
+ hash.inject({}) do |memo, val|
163
+ key, value = *val
164
+ case value.class.name
165
+ when 'Hash'
166
+ value = deep_recursive(value, &block)
167
+ when 'Array'
168
+ value = value.collect { |item| item.is_a?(Hash) ? deep_recursive(item, &block) : item }
169
+ end
170
+
171
+ key, value = yield(key, value)
172
+ memo.merge!(key => value)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,177 @@
1
+ require 'English'
2
+
3
+ module Wechat
4
+ module Responder
5
+ extend ActiveSupport::Concern
6
+ include Cipher
7
+
8
+ included do
9
+ skip_before_filter :verify_authenticity_token
10
+ before_filter :verify_signature, only: [:show, :create]
11
+ #delegate :wechat, to: :class
12
+ end
13
+
14
+ module ClassMethods
15
+ attr_accessor :wechat, :token, :corpid, :agentid, :encrypt_mode, :encoding_aes_key
16
+
17
+ def on(message_type, with: nil, respond: nil, &block)
18
+ raise 'Unknow message type' unless message_type.in? [:text, :image, :voice, :video, :location, :link, :event, :fallback]
19
+ config = respond.nil? ? {} : { respond: respond }
20
+ config.merge!(proc: block) if block_given?
21
+
22
+ if with.present? && !message_type.in?([:text, :event])
23
+ raise 'Only text and event message can take :with parameters'
24
+ else
25
+ config.merge!(with: with) if with.present?
26
+ end
27
+
28
+ responders(message_type) << config
29
+ config
30
+ end
31
+
32
+ def responders(type)
33
+ @responders ||= {}
34
+ @responders[type] ||= []
35
+ end
36
+
37
+ def responder_for(message, &block)
38
+ message_type = message[:MsgType].to_sym
39
+ responders = responders(message_type)
40
+
41
+ case message_type
42
+ when :text
43
+ yield(* match_responders(responders, message[:Content]))
44
+
45
+ when :event
46
+ if message[:Event] == 'click'
47
+ yield(* match_responders(responders, message[:EventKey]))
48
+ else
49
+ yield(* match_responders(responders, message[:Event]))
50
+ end
51
+ else
52
+ yield(responders.first)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def match_responders(responders, value)
59
+ matched = responders.inject({ scoped: nil, general: nil }) do |matched, responder|
60
+ condition = responder[:with]
61
+
62
+ if condition.nil?
63
+ matched[:general] ||= [responder, value]
64
+ next matched
65
+ end
66
+
67
+ if condition.is_a? Regexp
68
+ matched[:scoped] ||= [responder] + $LAST_MATCH_INFO.captures if value =~ condition
69
+ else
70
+ matched[:scoped] ||= [responder, value] if value == condition
71
+ end
72
+ matched
73
+ end
74
+ matched[:scoped] || matched[:general]
75
+ end
76
+ end
77
+
78
+ def show
79
+ if self.class.corpid.present?
80
+ echostr, _corp_id = unpack(decrypt(Base64.decode64(params[:echostr]), self.class.encoding_aes_key))
81
+ render text: echostr
82
+ else
83
+ render text: params[:echostr]
84
+ end
85
+ end
86
+
87
+ def create
88
+ request = Wechat::Message.from_hash(post_xml)
89
+ response = self.class.responder_for(request) do |responder, *args|
90
+ responder ||= self.class.responders(:fallback).first
91
+
92
+ next if responder.nil?
93
+ case
94
+ when responder[:respond]
95
+ request.reply.text responder[:respond]
96
+ when responder[:proc]
97
+ define_singleton_method :process, responder[:proc]
98
+ send(:process, *args.unshift(request))
99
+ else
100
+ next
101
+ end
102
+ end
103
+
104
+ if response.respond_to? :to_xml
105
+ render xml: process_response(response)
106
+ else
107
+ render nothing: true, status: 200, content_type: 'text/html'
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def verify_signature
114
+ array = [self.class.token, params[:timestamp], params[:nonce]]
115
+ signature = params[:signature]
116
+
117
+ # 默认使用明文方式验证, 企业号验证加密签名
118
+ if params[:signature].blank? && params[:msg_signature]
119
+ signature = params[:msg_signature]
120
+ if params[:echostr]
121
+ array << params[:echostr]
122
+ else
123
+ array << request_content['xml']['Encrypt']
124
+ end
125
+ end
126
+
127
+ str = array.compact.collect(&:to_s).sort.join
128
+ render text: 'Forbidden', status: 403 if signature != Digest::SHA1.hexdigest(str)
129
+ end
130
+
131
+ def post_xml
132
+ data = request_content
133
+
134
+ # 如果是加密模式解密
135
+ if self.class.encrypt_mode || self.class.corpid.present?
136
+ encrypt_msg = data['xml']['Encrypt']
137
+ if encrypt_msg.present?
138
+ content, @app_id = unpack(decrypt(Base64.decode64(encrypt_msg), self.class.encoding_aes_key))
139
+ data = Hash.from_xml(content)
140
+ end
141
+ end
142
+
143
+ HashWithIndifferentAccess.new_from_hash_copying_default(data.fetch('xml', {})).tap do |msg|
144
+ msg[:Event].downcase! if msg[:Event]
145
+ end
146
+ end
147
+
148
+ def process_response(response)
149
+ msg = response.to_xml
150
+
151
+ # 返回加密消息
152
+ if self.class.encrypt_mode || self.class.corpid.present?
153
+ data = request_content
154
+ if data['xml']['Encrypt']
155
+ encrypt = Base64.strict_encode64 encrypt(pack(msg, @app_id), self.class.encoding_aes_key)
156
+ msg = gen_msg(encrypt, params[:timestamp], params[:nonce])
157
+ end
158
+ end
159
+
160
+ msg
161
+ end
162
+
163
+ def gen_msg(encrypt, timestamp, nonce)
164
+ msg_sign = Digest::SHA1.hexdigest [self.class.token, encrypt, timestamp, nonce].compact.collect(&:to_s).sort.join
165
+
166
+ { Encrypt: encrypt,
167
+ MsgSignature: msg_sign,
168
+ TimeStamp: timestamp,
169
+ Nonce: nonce
170
+ }.to_xml(root: 'xml', children: 'item', skip_instruct: true, skip_types: true)
171
+ end
172
+
173
+ def request_content
174
+ params[:xml].nil? ? Hash.from_xml(request.raw_post) : { 'xml' => params[:xml] }
175
+ end
176
+ end
177
+ end