| @@ -2,10 +2,10 @@ package main | |||
| import ( | |||
| "encoding/json" | |||
| "errors" | |||
| "io/ioutil" | |||
| "log" | |||
| "os" | |||
| "time" | |||
| ) | |||
| //chat state that we might be use for collecting infomation from user | |||
| @@ -16,20 +16,37 @@ type chatState struct { | |||
| //below is managed by procedure only | |||
| //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 | |||
| 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 | |||
| 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 | |||
| func getCurrentState(openID string, procedure string) (result chatState, err error) { | |||
| path := getProcedurePath(openID, procedure) | |||
| //log.Printf("read state from %s\r\n", path) | |||
| body, err := ioutil.ReadFile(path) | |||
| if err != nil { //read file error | |||
| if isFileExist(path) { | |||
| @@ -42,33 +59,21 @@ func getCurrentState(openID string, procedure string) (result chatState, err err | |||
| log.Printf("Session Content [path=%s] not correct: ", path) | |||
| log.Println(err) | |||
| } | |||
| err = sanityCheckState(openID, procedure, result) | |||
| result.OpenID = openID | |||
| result.Procedure = procedure | |||
| 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 { | |||
| return | |||
| } | |||
| path := getProcedurePath(openID, procedure) | |||
| path := getProcedurePath(m.OpenID, m.Procedure) | |||
| err = ioutil.WriteFile(path, j, 0600) | |||
| if err != nil { | |||
| log.Println("write state error" + path) | |||
| @@ -78,38 +83,16 @@ func setCurrentState(openID, procedure string, state chatState) (err error) { | |||
| 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 | |||
| } | |||
| 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 false | |||
| @@ -2,40 +2,39 @@ package main | |||
| import ( | |||
| "testing" | |||
| "time" | |||
| ) | |||
| func TestChatState(t *testing.T) { | |||
| openID := "id" | |||
| procedure := "getUserBasicProfile" | |||
| s := chatState{} | |||
| s := createEmptyState(openID, procedure, 100) | |||
| 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?", | |||
| "icon": "/mnt/data/abc.jpg", | |||
| } | |||
| s.response = "somexml less than 2018bytes" | |||
| s.response = "somexml less than 2048 bytes" | |||
| //save | |||
| err := setCurrentState(openID, procedure, s) | |||
| err := s.Save() | |||
| AssertEqual(t, err, nil, "save state should be successful") | |||
| //read out | |||
| m, _ := getCurrentState(openID, procedure) | |||
| m := chatState{} | |||
| m.Load(openID, procedure) | |||
| //compare | |||
| AssertEqual(t, m.Name, s.Name, "Name 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.response, "", "response should be empty") | |||
| 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") | |||
| } | |||
| @@ -9,15 +9,14 @@ import ( | |||
| func echoCommand(openID string, in InWechatMsg) (state chatState, processed bool) { | |||
| processed = true | |||
| str, err := BuildTextMsg(openID, "default") | |||
| log.Println(in.header.MsgType) | |||
| log.Println("echoCommand :" + in.header.MsgType) | |||
| switch in.body.(type) { | |||
| case TextMsg: | |||
| m := in.body.(TextMsg) | |||
| str, err = BuildTextMsg(openID, m.Content+"\n\n关键词 [转接] 将后续信息都转接到 客服") | |||
| str, err = BuildTextMsg(openID, m.Content+"\n\n关键词 [转接] 将后续信息都转接到 客服 测试版") | |||
| if m.Content == "转接" { | |||
| processed = false | |||
| } | |||
| case PicMsg: | |||
| m := in.body.(PicMsg) | |||
| str = buildPicMsg(openID, m.MediaId) | |||
| @@ -53,7 +52,7 @@ func echoCommand(openID string, in InWechatMsg) (state chatState, processed bool | |||
| state.OpenID = openID | |||
| state.Procedure = "" | |||
| state.response = str | |||
| log.Println(str) | |||
| //log.Println(str) | |||
| if err != nil { | |||
| log.Println("build response failed") | |||
| processed = false | |||
| @@ -6,7 +6,7 @@ import ( | |||
| "time" | |||
| ) | |||
| var toUser = "oUN420bxqFqlx0ZQHciUOesZO3PE" | |||
| var toUser = "oUN420Wj78vnkNeAJY7RMPXA28oc" // "oUN420bxqFqlx0ZQHciUOesZO3PE" | |||
| func TestSendTxt(t *testing.T) { | |||
| SetupConfig() | |||
| @@ -1,106 +1,34 @@ | |||
| package main | |||
| import ( | |||
| "errors" | |||
| "log" | |||
| "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 | |||
| } | |||
| @@ -1,10 +1,57 @@ | |||
| package main | |||
| 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 | |||
| } | |||
| @@ -54,17 +54,11 @@ func answerInitialAuth(w http.ResponseWriter, r *http.Request) { | |||
| } | |||
| } | |||
| // | |||
| //InWechatMsg what we received currently from wechat | |||
| type InWechatMsg struct { | |||
| header CommonHeader | |||
| body interface{} //dynamic type | |||
| } | |||
| // | |||
| //answerWechatPost distribute PostRequest according to xml body info | |||
| func answerWechatPost(w http.ResponseWriter, r *http.Request) { | |||
| in, valid := readWechatInput(r) | |||
| in.instantResponse = make(chan string) | |||
| reply := "" //nothing | |||
| w.Header().Set("Content-Type", "text/xml; charset=utf-8") | |||
| @@ -75,6 +69,9 @@ func answerWechatPost(w http.ResponseWriter, r *http.Request) { | |||
| AllInMessage <- in | |||
| } | |||
| //read instant response | |||
| reply = <-in.instantResponse | |||
| close(in.instantResponse) | |||
| //uncomment the followin two lines to enable echo test | |||
| // state, _ := echoCommand(in.header.FromUserName, in) | |||
| // reply = state.response | |||
| @@ -10,8 +10,7 @@ type openIDSession struct { | |||
| openID string //who is this? | |||
| count int // number of message in the Queue, channel | |||
| 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 { | |||
| @@ -81,8 +80,9 @@ func (m *SessionManager) createSession(openID string) openIDSession { | |||
| s.openID = openID | |||
| s.count = 0 | |||
| 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 | |||
| } | |||
| @@ -92,9 +92,6 @@ func (m *SessionManager) clearJobDone(d workDone) { | |||
| if found { | |||
| s.count -= d.consumed | |||
| if s.count == 0 { //no job to do | |||
| //save session data to disk | |||
| data := m.sessions[d.openID].data | |||
| data.Save() | |||
| //remove from memory | |||
| m.destroySession(d.openID) | |||
| log.Printf("destroy session %s", d.openID) | |||
| @@ -111,6 +108,10 @@ func (m *SessionManager) clearJobDone(d workDone) { | |||
| } | |||
| func (m *SessionManager) destroySession(openID string) { | |||
| //save session data to disk | |||
| data := m.sessions[openID].data | |||
| data.Save() | |||
| //close job channels | |||
| s := m.sessions[openID] | |||
| close(s.jobs) | |||
| @@ -36,4 +36,4 @@ func isJSON(r *http.Response) bool { | |||
| return false | |||
| } | |||
| //To Do: test error message @todo | |||
| //ToDo: test error message @todo | |||
| @@ -0,0 +1,24 @@ | |||
| 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 | |||
| } | |||