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