| import ( | import ( | ||||
| "encoding/json" | "encoding/json" | ||||
| "errors" | |||||
| "io/ioutil" | "io/ioutil" | ||||
| "log" | "log" | ||||
| "os" | "os" | ||||
| "time" | |||||
| ) | ) | ||||
| //chat state that we might be use for collecting infomation from user | //chat state that we might be use for collecting infomation from user | ||||
| //below is managed by procedure only | //below is managed by procedure only | ||||
| //real state information | //real state information | ||||
| Name string `json:"Name"` //state name | |||||
| Expire int32 `json:"Expire"` //unix timestamp when this state expire | |||||
| Name string `json:"Name"` //state name | |||||
| CreateAt int32 `json:"CreateAt"` //unix timestamp , when its created | |||||
| Expire int32 `json:"Expire"` //unix timestamp when this state expire | |||||
| //persistant sate | //persistant sate | ||||
| Save map[string]string `json:"Save"` //the state save some data for later usage | |||||
| Data map[string]string `json:"Data"` //the state save some data for later usage | |||||
| //runtime memory only | //runtime memory only | ||||
| response string //被动回复消息的内容 | response string //被动回复消息的内容 | ||||
| } | } | ||||
| func createEmptyState(openID, procedure string, expireInSeconds int32) chatState { | |||||
| r := chatState{} | |||||
| r.Name = "" | |||||
| r.OpenID = openID | |||||
| r.Procedure = procedure | |||||
| r.CreateAt = int32(time.Now().Unix()) | |||||
| r.Expire = r.CreateAt + expireInSeconds | |||||
| r.Data = map[string]string{} | |||||
| return r | |||||
| } | |||||
| func (m *chatState) Load(openID, procedure string) (result chatState, err error) { | |||||
| result, err = getCurrentState(openID, procedure) | |||||
| *m = result | |||||
| return | |||||
| } | |||||
| //for individual state | //for individual state | ||||
| func getCurrentState(openID string, procedure string) (result chatState, err error) { | func getCurrentState(openID string, procedure string) (result chatState, err error) { | ||||
| path := getProcedurePath(openID, procedure) | path := getProcedurePath(openID, procedure) | ||||
| //log.Printf("read state from %s\r\n", path) | |||||
| body, err := ioutil.ReadFile(path) | body, err := ioutil.ReadFile(path) | ||||
| if err != nil { //read file error | if err != nil { //read file error | ||||
| if isFileExist(path) { | if isFileExist(path) { | ||||
| log.Printf("Session Content [path=%s] not correct: ", path) | log.Printf("Session Content [path=%s] not correct: ", path) | ||||
| log.Println(err) | log.Println(err) | ||||
| } | } | ||||
| err = sanityCheckState(openID, procedure, result) | |||||
| result.OpenID = openID | |||||
| result.Procedure = procedure | |||||
| return | return | ||||
| } | } | ||||
| func sanityCheckState(openID, procedure string, result chatState) (err error) { | |||||
| //check whether state is for the correct openID and procedure | |||||
| if result.OpenID != openID { | |||||
| err = errors.New("Error: State for " + openID + " is actually for " + result.OpenID) | |||||
| return | |||||
| func (m *chatState) Save() (err error) { | |||||
| if m.isEndingState() { | |||||
| return m.Delete() | |||||
| } | } | ||||
| if result.Procedure != procedure { | |||||
| err = errors.New("Error: Proecdure for " + procedure + " is actually for " + result.Procedure) | |||||
| return | |||||
| } | |||||
| return | |||||
| } | |||||
| func setCurrentState(openID, procedure string, state chatState) (err error) { | |||||
| state.OpenID = openID | |||||
| state.Procedure = procedure | |||||
| j, err := json.Marshal(state) | |||||
| //save to disk | |||||
| j, err := json.Marshal(*m) | |||||
| if err != nil { | if err != nil { | ||||
| return | return | ||||
| } | } | ||||
| path := getProcedurePath(openID, procedure) | |||||
| path := getProcedurePath(m.OpenID, m.Procedure) | |||||
| err = ioutil.WriteFile(path, j, 0600) | err = ioutil.WriteFile(path, j, 0600) | ||||
| if err != nil { | if err != nil { | ||||
| log.Println("write state error" + path) | log.Println("write state error" + path) | ||||
| return | return | ||||
| } | } | ||||
| func deleteChatState(openID, procedure string) (err error) { | |||||
| path := getProcedurePath(openID, procedure) | |||||
| err = os.Remove(path) | |||||
| return | |||||
| } | |||||
| //ValidationResult After input validation, what is the result | |||||
| type ValidationResult struct { | |||||
| accept bool | |||||
| Hint string | |||||
| Error string | |||||
| Warning string | |||||
| } | |||||
| //Validator function type for validating all wechat inputs | |||||
| type Validator func(s chatState) ValidationResult | |||||
| func saveChatState(openID, procedure string, state chatState) (err error) { | |||||
| if isExpired(state.Expire) { | |||||
| //skip saving sate | |||||
| return | |||||
| func (m *chatState) Delete() (err error) { | |||||
| path := getProcedurePath(m.OpenID, m.Procedure) | |||||
| if isFileExist(path) { | |||||
| err = os.Remove(path) | |||||
| } | } | ||||
| err = setCurrentState(openID, procedure, state) | |||||
| return | return | ||||
| } | } | ||||
| func isEndingState(state chatState) bool { | |||||
| if isExpired(state.Expire) || state.Name == "" { | |||||
| return true | |||||
| } | |||||
| if state.Name == "delete" { | |||||
| func (m *chatState) isEndingState() bool { | |||||
| if isExpired(m.Expire) || m.Name == "" { | |||||
| return true | return true | ||||
| } | } | ||||
| return false | return false |
| import ( | import ( | ||||
| "testing" | "testing" | ||||
| "time" | |||||
| ) | ) | ||||
| func TestChatState(t *testing.T) { | func TestChatState(t *testing.T) { | ||||
| openID := "id" | openID := "id" | ||||
| procedure := "getUserBasicProfile" | procedure := "getUserBasicProfile" | ||||
| s := chatState{} | |||||
| s := createEmptyState(openID, procedure, 100) | |||||
| s.Name = "waiting for username" | s.Name = "waiting for username" | ||||
| s.Expire = int32(time.Now().Unix() + 200) | |||||
| s.Save = map[string]string{ | |||||
| s.Data = map[string]string{ | |||||
| "txt": "What is your date of birth?", | "txt": "What is your date of birth?", | ||||
| "icon": "/mnt/data/abc.jpg", | "icon": "/mnt/data/abc.jpg", | ||||
| } | } | ||||
| s.response = "somexml less than 2018bytes" | |||||
| s.response = "somexml less than 2048 bytes" | |||||
| //save | //save | ||||
| err := setCurrentState(openID, procedure, s) | |||||
| err := s.Save() | |||||
| AssertEqual(t, err, nil, "save state should be successful") | AssertEqual(t, err, nil, "save state should be successful") | ||||
| //read out | //read out | ||||
| m, _ := getCurrentState(openID, procedure) | |||||
| m := chatState{} | |||||
| m.Load(openID, procedure) | |||||
| //compare | //compare | ||||
| AssertEqual(t, m.Name, s.Name, "Name should be equal") | AssertEqual(t, m.Name, s.Name, "Name should be equal") | ||||
| AssertEqual(t, m.Expire, s.Expire, "Expire should be equal") | AssertEqual(t, m.Expire, s.Expire, "Expire should be equal") | ||||
| AssertEqual(t, m.Save["txt"], s.Save["txt"], "Message[txt] should be equal") | |||||
| AssertEqual(t, m.Save["icon"], s.Save["icon"], "Message[icon] should be equal") | |||||
| AssertEqual(t, m.Data["txt"], s.Data["txt"], "Message[txt] should be equal") | |||||
| AssertEqual(t, m.Data["icon"], s.Data["icon"], "Message[icon] should be equal") | |||||
| AssertEqual(t, m.OpenID, openID, "openID should be "+openID) | AssertEqual(t, m.OpenID, openID, "openID should be "+openID) | ||||
| AssertEqual(t, m.response, "", "response should be empty") | AssertEqual(t, m.response, "", "response should be empty") | ||||
| AssertEqual(t, m.Procedure, procedure, "procedure should be "+procedure) | AssertEqual(t, m.Procedure, procedure, "procedure should be "+procedure) | ||||
| AssertEqual(t, m.Save["txt"], s.Save["txt"], "Message[txt] should be equal") | |||||
| AssertEqual(t, m.Save["icon"], s.Save["icon"], "Message[icon] should be equal") | |||||
| AssertEqual(t, m.Data["txt"], s.Data["txt"], "Message[txt] should be equal") | |||||
| AssertEqual(t, m.Data["icon"], s.Data["icon"], "Message[icon] should be equal") | |||||
| err = deleteChatState(openID, procedure) | |||||
| err = s.Delete() | |||||
| AssertEqual(t, err, nil, "delete chatState should be good") | AssertEqual(t, err, nil, "delete chatState should be good") | ||||
| } | } |
| func echoCommand(openID string, in InWechatMsg) (state chatState, processed bool) { | func echoCommand(openID string, in InWechatMsg) (state chatState, processed bool) { | ||||
| processed = true | processed = true | ||||
| str, err := BuildTextMsg(openID, "default") | str, err := BuildTextMsg(openID, "default") | ||||
| log.Println(in.header.MsgType) | |||||
| log.Println("echoCommand :" + in.header.MsgType) | |||||
| switch in.body.(type) { | switch in.body.(type) { | ||||
| case TextMsg: | case TextMsg: | ||||
| m := in.body.(TextMsg) | m := in.body.(TextMsg) | ||||
| str, err = BuildTextMsg(openID, m.Content+"\n\n关键词 [转接] 将后续信息都转接到 客服") | |||||
| str, err = BuildTextMsg(openID, m.Content+"\n\n关键词 [转接] 将后续信息都转接到 客服 测试版") | |||||
| if m.Content == "转接" { | if m.Content == "转接" { | ||||
| processed = false | processed = false | ||||
| } | } | ||||
| case PicMsg: | case PicMsg: | ||||
| m := in.body.(PicMsg) | m := in.body.(PicMsg) | ||||
| str = buildPicMsg(openID, m.MediaId) | str = buildPicMsg(openID, m.MediaId) | ||||
| state.OpenID = openID | state.OpenID = openID | ||||
| state.Procedure = "" | state.Procedure = "" | ||||
| state.response = str | state.response = str | ||||
| log.Println(str) | |||||
| //log.Println(str) | |||||
| if err != nil { | if err != nil { | ||||
| log.Println("build response failed") | log.Println("build response failed") | ||||
| processed = false | processed = false |
| "time" | "time" | ||||
| ) | ) | ||||
| var toUser = "oUN420bxqFqlx0ZQHciUOesZO3PE" | |||||
| var toUser = "oUN420Wj78vnkNeAJY7RMPXA28oc" // "oUN420bxqFqlx0ZQHciUOesZO3PE" | |||||
| func TestSendTxt(t *testing.T) { | func TestSendTxt(t *testing.T) { | ||||
| SetupConfig() | SetupConfig() |
| package main | package main | ||||
| import ( | import ( | ||||
| "errors" | |||||
| "log" | |||||
| "os" | "os" | ||||
| "time" | |||||
| ) | ) | ||||
| //start a procedure | |||||
| func startProcedure(openID, procedure string) (err error) { | |||||
| //init procedure state | |||||
| init := getProcedureInit(openID, procedure) | |||||
| if init == nil { | |||||
| msg := "FATAL: cannot initialize procedure [" + procedure + "] " | |||||
| err = errors.New(msg) | |||||
| return | |||||
| } | |||||
| //init and get initial state | |||||
| state := init(openID) | |||||
| // a description of | |||||
| type chatProcedure struct { | |||||
| init func() //house keeping | |||||
| clean func() //house keeping | |||||
| //save it | |||||
| setCurrentState(openID, procedure, state) | |||||
| //next is to waiting for user's input | |||||
| //which may or may not happen very soon | |||||
| return | |||||
| } | |||||
| //resume a previous Procedure's state | |||||
| func resumeProcedure(openID, procedure string) (err error) { | |||||
| state, err := getCurrentState(openID, procedure) | |||||
| if err != nil { | |||||
| return | |||||
| } | |||||
| //re-introduce what we are doing | |||||
| // showProcIntro(openID, peocedure) | |||||
| //tell user what has been achieved | |||||
| // showProcSumary(openID, procedure) | |||||
| return processProcedureState(state) | |||||
| } | |||||
| //finish a procedure, regardless its been finished or not | |||||
| //normally not finished normally | |||||
| func cleanProcedure(openID, procedure string) { | |||||
| path := getProcedurePath(openID, procedure) | |||||
| os.Remove(path) | |||||
| log.Println("Clearing [" + openID + "] @ [" + procedure + "]") | |||||
| //TODO: save session to "" procedure | |||||
| } | |||||
| func processProcedureState(state chatState) (err error) { | |||||
| //send what we need to send | |||||
| if isExpired(state.Expire) { | |||||
| return errors.New("State has expired " + state.Name) | |||||
| } | |||||
| //mark we have sent. | |||||
| //do we need input? waiting for input | |||||
| //if not, what is next state | |||||
| log.Println(state) | |||||
| return | |||||
| } | |||||
| func getProcedureInit(openID, procedure string) initProcFunc { | |||||
| initFunc := map[string]initProcFunc{ | |||||
| "TestDummy": nil, | |||||
| "TestEcho": initTestEcho, | |||||
| "GetBasicUserInfo": initGetBasicUserInfo, | |||||
| "GetEmailAddr": initGetBasicUserInfo, | |||||
| } | |||||
| return initFunc[procedure] | |||||
| } | |||||
| func initTestEcho(openid string) (r chatState) { | |||||
| r.Name = openid | |||||
| r.Expire = int32(time.Now().Unix() + 100) | |||||
| return | |||||
| start func(*openIDSessionData, InWechatMsg) //for first message | |||||
| serve func(*openIDSessionData, InWechatMsg) //for all subsequent message | |||||
| summary func() //after all message has been done | |||||
| intro func() //initial text/video/voice introduction | |||||
| } | } | ||||
| //are we inside a procedure, and not finished? | |||||
| func isInProc(openID string) (result bool, state chatState) { | |||||
| r, err := getCurrentSesssion(openID) | |||||
| if err != nil { | |||||
| return false, state | |||||
| } | |||||
| //AllProc all procedure that we implemented | |||||
| var AllProc = map[string]chatProcedure{} | |||||
| if isExpired(r.Expire) { | |||||
| return false, state | |||||
| } | |||||
| func initAllProc() { | |||||
| AllProc["Dummy"] = procDummy | |||||
| //Simple Echo Proc | |||||
| AllProc["Echo"] = procEcho | |||||
| //Get Basic UserInfo | |||||
| AllProc["GetUserBasicInfo"] = procGetBasicUserInfo | |||||
| state, err = getCurrentState(openID, r.Procedure) | |||||
| if err != nil || isExpired(state.Expire) { | |||||
| return false, state | |||||
| } | |||||
| return true, state | |||||
| } | } | ||||
| //follow procedure, if there is any | |||||
| func serveProc(state chatState, input InWechatMsg) (next chatState) { | |||||
| func getProcedurePath(openID, ProcedureName string) (path string) { | |||||
| path = procedureDir + string(os.PathSeparator) + ProcedureName + string(os.PathSeparator) + openID + ".json" | |||||
| ensurePathExist(path) | |||||
| return | return | ||||
| } | } |
| package main | package main | ||||
| import ( | import ( | ||||
| "log" | |||||
| "fmt" | |||||
| "time" | |||||
| ) | ) | ||||
| func serveCommand(openID string, in InWechatMsg) (state chatState, processed bool) { | |||||
| log.Println("process command") | |||||
| return echoCommand(openID, in) | |||||
| type commandFunction func(*openIDSessionData, InWechatMsg) bool | |||||
| var commandMap = map[string]commandFunction{ | |||||
| "版本信息": cmdVersion, | |||||
| "version": cmdVersion, | |||||
| //"所有命令": allCommand, //include it will cause initialization loop | |||||
| "echo": cmdEcho, | |||||
| "回音": cmdEcho, | |||||
| } | |||||
| func (ss *openIDSessionData) serveCommand(in InWechatMsg) (processed bool) { | |||||
| if in.header.MsgType == "text" { | |||||
| cmd := in.body.(TextMsg).Content | |||||
| if f, hasFunction := commandMap[cmd]; hasFunction { | |||||
| return f(ss, in) | |||||
| } | |||||
| if cmd == "所有命令" || cmd == "all command" { | |||||
| return allCommand(ss, in) | |||||
| } | |||||
| } | |||||
| processed = false | |||||
| return | |||||
| } | |||||
| func allCommand(ss *openIDSessionData, in InWechatMsg) (processed bool) { | |||||
| processed = true | |||||
| msg := "命令如下:\n" | |||||
| count := 0 | |||||
| for k := range commandMap { | |||||
| count++ | |||||
| msg = msg + fmt.Sprintf("%0d : %s \n", count, k) | |||||
| } | |||||
| str, _ := BuildTextMsg(in.header.FromUserName, msg) | |||||
| in.immediateResponse(str) | |||||
| return | |||||
| } | |||||
| func cmdVersion(ss *openIDSessionData, in InWechatMsg) (processed bool) { | |||||
| processed = true | |||||
| str, _ := BuildTextMsg(in.header.FromUserName, "这是测试版本"+time.Now().Format("2006/01/02 03:04:05")) | |||||
| in.immediateResponse(str) | |||||
| return | |||||
| } | |||||
| func cmdEcho(ss *openIDSessionData, in InWechatMsg) (processed bool) { | |||||
| procEcho.init() | |||||
| procEcho.start(ss, in) | |||||
| return | |||||
| } | } |
| } | } | ||||
| } | } | ||||
| // | |||||
| //InWechatMsg what we received currently from wechat | |||||
| type InWechatMsg struct { | |||||
| header CommonHeader | |||||
| body interface{} //dynamic type | |||||
| } | |||||
| // | // | ||||
| //answerWechatPost distribute PostRequest according to xml body info | //answerWechatPost distribute PostRequest according to xml body info | ||||
| func answerWechatPost(w http.ResponseWriter, r *http.Request) { | func answerWechatPost(w http.ResponseWriter, r *http.Request) { | ||||
| in, valid := readWechatInput(r) | in, valid := readWechatInput(r) | ||||
| in.instantResponse = make(chan string) | |||||
| reply := "" //nothing | reply := "" //nothing | ||||
| w.Header().Set("Content-Type", "text/xml; charset=utf-8") | w.Header().Set("Content-Type", "text/xml; charset=utf-8") | ||||
| AllInMessage <- in | AllInMessage <- in | ||||
| } | } | ||||
| //read instant response | |||||
| reply = <-in.instantResponse | |||||
| close(in.instantResponse) | |||||
| //uncomment the followin two lines to enable echo test | //uncomment the followin two lines to enable echo test | ||||
| // state, _ := echoCommand(in.header.FromUserName, in) | // state, _ := echoCommand(in.header.FromUserName, in) | ||||
| // reply = state.response | // reply = state.response |
| openID string //who is this? | openID string //who is this? | ||||
| count int // number of message in the Queue, channel | count int // number of message in the Queue, channel | ||||
| jobs chan InWechatMsg | jobs chan InWechatMsg | ||||
| data openIDSessionData //session data, that needs to be saved to disk | |||||
| states map[string]chatState //map from procedure to its current states | |||||
| data openIDSessionData //session data, that needs to be saved to disk | |||||
| } | } | ||||
| type workDone struct { | type workDone struct { | ||||
| s.openID = openID | s.openID = openID | ||||
| s.count = 0 | s.count = 0 | ||||
| s.jobs = make(chan InWechatMsg, 50) | s.jobs = make(chan InWechatMsg, 50) | ||||
| s.data, _ = getCurrentSesssion(openID) //either load or create new | |||||
| m.sessions[openID] = s //register it to memory | |||||
| s.data = openIDSessionData{} | |||||
| s.data.Load(openID) //either load or create new | |||||
| m.sessions[openID] = s //register it to memory | |||||
| return s | return s | ||||
| } | } | ||||
| if found { | if found { | ||||
| s.count -= d.consumed | s.count -= d.consumed | ||||
| if s.count == 0 { //no job to do | if s.count == 0 { //no job to do | ||||
| //save session data to disk | |||||
| data := m.sessions[d.openID].data | |||||
| data.Save() | |||||
| //remove from memory | //remove from memory | ||||
| m.destroySession(d.openID) | m.destroySession(d.openID) | ||||
| log.Printf("destroy session %s", d.openID) | log.Printf("destroy session %s", d.openID) | ||||
| } | } | ||||
| func (m *SessionManager) destroySession(openID string) { | func (m *SessionManager) destroySession(openID string) { | ||||
| //save session data to disk | |||||
| data := m.sessions[openID].data | |||||
| data.Save() | |||||
| //close job channels | //close job channels | ||||
| s := m.sessions[openID] | s := m.sessions[openID] | ||||
| close(s.jobs) | close(s.jobs) |
| return false | return false | ||||
| } | } | ||||
| //To Do: test error message @todo | |||||
| //ToDo: test error message @todo |
| package main | |||||
| import "net/http" | |||||
| // | |||||
| //InWechatMsg what we received currently from wechat | |||||
| type InWechatMsg struct { | |||||
| header CommonHeader | |||||
| body interface{} //dynamic type | |||||
| req *http.Request | |||||
| instantResponse chan string //instance reply channel | |||||
| } | |||||
| func (m *InWechatMsg) init() { | |||||
| m.instantResponse = make(chan string) | |||||
| } | |||||
| func (m *InWechatMsg) destroy() { | |||||
| close(m.instantResponse) | |||||
| } | |||||
| func (m *InWechatMsg) immediateResponse(s string) { | |||||
| m.instantResponse <- s | |||||
| } |