├── .github └── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── feature_request.yml │ └── info_note.yml ├── .gitignore ├── LICENSE ├── README.md ├── api ├── api.go ├── doc.go ├── opentable │ ├── api.go │ └── doc.go └── resy │ ├── api.go │ └── doc.go ├── app ├── app.go └── doc.go ├── assets ├── help.png ├── prompt.png ├── rats.png ├── search.png └── search_bracket.png ├── cli ├── doc.go └── parse.go ├── go.mod ├── go.sum ├── main.go └── runnable ├── cli ├── doc.go └── runnable.go ├── doc.go └── runnable.go /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report by filling out the following 3 | title: "[BUG]: " 4 | labels: ["bug"] 5 | body: 6 | - type: checkboxes 7 | id: new-bug 8 | attributes: 9 | label: Is there an existing issue for this bug? 10 | description: Please ensure that there is no issues for this bug already 11 | options: 12 | - label: I have searched for this issue 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: bug-description 17 | attributes: 18 | label: Description of the bug 19 | description: Describe the bug that occured and the expected behavior 20 | validations: 21 | required: true 22 | - type: textarea 23 | id: bug-steps 24 | attributes: 25 | label: Steps to reproduce 26 | description: Step by step procedure to reproduce the bug 27 | placeholder: Write steps in list form 28 | validations: 29 | required: true 30 | - type: textarea 31 | id: bug-spec 32 | attributes: 33 | label: System Specs 34 | description: Specification of system that the bug occured on 35 | validations: 36 | required: true 37 | - type: textarea 38 | id: bug-add-ctx 39 | attributes: 40 | label: Additional Context 41 | description: Write any additional facts that you think might be helpful here 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: File a feature request by filling out the following 3 | title: "[FEATURE]: " 4 | labels: ["enhancement", "pending"] 5 | body: 6 | - type: checkboxes 7 | id: new-feature 8 | attributes: 9 | label: Is there an existing issue for this feature? 10 | description: Please ensure that there is no issues for this feature already exist 11 | options: 12 | - label: I have searched for this issue 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: feature-problem 17 | attributes: 18 | label: Description of the problem 19 | description: Describe the problem that you have and potential groups effected by it (i.e. developers, users, etc.) 20 | validations: 21 | required: true 22 | - type: textarea 23 | id: feature-plan 24 | attributes: 25 | label: Planned Solution 26 | description: Describe the solution you have formulated at an overhead level 27 | validations: 28 | required: true 29 | - type: textarea 30 | id: feature-alternatives 31 | attributes: 32 | label: Alternatives 33 | description: Describe the alternatives you have considered or are considering, and why you chose your solution over these alternatives 34 | validations: 35 | required: true 36 | - type: textarea 37 | id: feature-specifics 38 | attributes: 39 | label: Solution Specifics 40 | description: Describe any specific components, algorithms, or any other nitty-gritty ideas you are considering using to implement your solution 41 | validations: 42 | required: true 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/info_note.yml: -------------------------------------------------------------------------------- 1 | name: Info Note 2 | description: Leave a general note for sharing info / discussing something 3 | title: "[INFO]: " 4 | labels: ["information"] 5 | body: 6 | - type: textarea 7 | id: information 8 | attributes: 9 | label: Information to share 10 | description: Describe the info you want people to know about 11 | validations: 12 | required: true 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | *.DS_Store 4 | *.exe 5 | *~ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Bruce Jagid 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # resolved-bot 2 | ## IMPORTANT UPDATE 3 | We currently have no plans to keep the bot up to date, and it is probably not functional. All proposed diffs and patches from the community will still be actively reviewed however. 4 | ## Description 5 | Resolved is a combination of the following: 6 | 1. A go API to resy 7 | 2. An application on top of the API that can perform concurrent tasks such as repeating a reservation request at differing time intervals or scheduling a reservation request until a specified reservation drop date 8 | 3. A command prompt interface to the application 9 | 4. An HTTP server interface to the application 10 | 11 | So far, items 1-3 are implemented, with future plans in the issues section labelled "enhancements" 12 | 13 | 14 | ## Steps to build: 15 | 16 | 1. Install the go programming language, this can be done by searching "golang installation" 17 | 2. clone this repository 18 | 3. run `go build` in root directory of project 19 | 20 | If you would like to test new features, the `develop` branch is a decently tested staging area for new 21 | feautres, usually 3 features ahead of `main`. 22 | 23 | ## How to use: 24 | 25 | On a successful start up, one should be prompted with the CLI welcome message: 26 | ![prompt](./assets/prompt.png) 27 | 28 | Typing `help` yields: 29 | ![help](./assets/help.png) 30 | 31 | I'll explain each command, but not in the order they appear: 32 | 1. `search` takes in one required input, the name of restuarant you want to search for, and one optional input, a number to limit the results by. The name of the restaurant is specified with the `-n` flag(or alternatively the `--name` flag) and the limit is specified with the `-l`(alternatively `--limit`) flag. 33 | 34 | Here is an example use of a search using a name of carbone and limit of 5: 35 | ![search](./assets/search.png) 36 | 37 | What if the restaurant name has spaces? Just wrap it in square brackets. In this next example, we'll search for "double chicken please": 38 | 39 | ![search_bracket](./assets/search_bracket.png) 40 | 41 | The purpose of searching is to obtain the `VenueID`. This number is a unique identifier that resy uses to find the restaurant that you want to reserve at, since multiple restaurants can have the same name. 42 | 43 | 2. `login` takes in two required inputs, the email(`-e` flag) and password(`-p` flag) associated with your resy login. It then checks to see if these inputs are a valid login to resy. This command is useful if you intend to use any other command that has an email and password, as these will be used as defaults 44 | 3. `logout` clears the defaults set by a login if you want to erase your credentials from the system 45 | 4. `rats` is one of the two core function commands of the bot(the other being `rais`). Its main use is to schedule a time to try to get a reservation at. For example, some restaurants will only release available seating for a given day six days before. So if I want to reserve an 11 PM on the 7th, I have 46 | to be one of the lucky few who press "reserve" fast enough on the 1st. `rats` will automate this task for you. 47 | 48 | Here is an example use: 49 | 50 | ![rats](./assets/rats.png) 51 | 52 | I'll walk through what each flag does: 53 | 1. `-v` or `--venue-id` specifies the unique identifier for which we want to make a reservation. We're using double chicken please's id from the search example. 54 | 2. `-resD` or `--reservation-day` specifies the day that we want our reservation at in yyyy:mm:dd format. In this case, we're setting the day to be september 7th, 2023 55 | 3. `-resT` or `--reservation-times` specifies the military style times we want to make our reservation at in hh:mm format and in order of priority. In this case, we only have one time, but we could list as many times as we want in that area, and if the first n fail, it will try the n+1th 56 | 4. `-ps` or `--party-size` specifies the party size, in this case 2 people 57 | 5. `-reqD` or `--request-date` specifies the date at which we want to make the request(basically the date we want to press the "reserve" button at). This should be supplied in military time, with format yyyy:mm:dd:hh:mm (year:month:day:hour:minute). In this case, we want to press 58 | "reserve" at midnight on september 1st, 2023. 59 | 6. `-t` or `--table` is an optional flag that allows the user to specify a priority list of table types. So an example input is `-t outdoor dining`. The priority of reservations in this case is all times in `-resT` will be tried in priority order with the first table type specified, then another iteration of all reservation times with a second table type. 60 | 61 | Other flags include a `-e` flag for email and `-p` for password, but if logged in this is not needed(actually if you are logged in but don't want to use the login credentials you used in the login command, specifying `-e` and `-p` flags here will override those credentials for this command) 62 | The output of this command on success is an ID number. This can be used in later commands to see the status(whether it failed or succeeded) or to cancel the operation. 63 | 64 | 5. `rais` is one of the two core function commands of the bot(the other being `rats`). Its main use is to try to make a reservation at a repeating interval. 65 | 66 | The inputs are very similiar to `rats`: 67 | 1. `-v` or `--venue-id` specifies the unique identifier for which we want to make a reservation. 68 | 2. `-resD` or `--reservation-day` specifies the day that we want our reservation at in yyyy:mm:dd format. 69 | 3. `-resT` or `--reservation-times` specifies the military style times we want to make our reservation at in hh:mm format and in order of priority. 70 | 4. `-ps` or `--party-size` specifies the party size, in this case 2 people 71 | 5. `-i` or `--interval` specifies the interval to repeat on in hh:mm format. So, if we used all the same parameters for the previous example with `-i 00:01`, then this command would try to make an 11:30 PM reservation at double chicken please for september 7th, 2023, and the bot would perform this request every minute (00 for hour, 01 for minute) 72 | 6. `-t` or `--table` is an optional flag that allows the user to specify a priority list of table types. So an example input is `-t outdoor dining`. The priority of reservations in this case is all times in `-resT` will be tried in priority order with the first table type specified, then another iteration of all reservation times with a second table type. 73 | 74 | Here are the last remaining commands: 75 | 76 | 6. `cancel` takes in a list of ids using the `-i` flag and tries to cancel the operation associated with each id. In the rats example, the operation has id 0, so calling `cancel -i 0` will cancel the rats operation 77 | 7. `list` takes in no input and outputs a list of each operation's id, its status(failed, succeeded, cancelled, etc.) and the result(error if failed, reservation time if succeeded) 78 | 8. `clean` takes in a list of ids using the `-i` flag and will remove the operation information from the system(i.e. it will no longer be displayed from the list command). Operations can only be cleaned once they are no longer in progress. 79 | 9. `exit/quit` leaves the prompt 80 | 10. `help` outputs helpful information about each command 81 | 82 | ## How To Contribute 83 | 84 | I am open and happy to accept contributions from anyone who wants to offer them. To get started on this, read the `Contribution Etiquette` post in the issue section of this project, which should be pinned. Another document which might be helpful in getting adjusted to the project is the `Architecture Overview` issue post, which provides an overhead view to what each part of the codebase does. Generally, any helpful information can be found in the issue section labelled with `information`. I'm avaialble at `brucejagid@gmail.com`; feel free to send any questions to that address. 85 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | /* 2 | Author: Bruce Jagid 3 | Created On: Aug 21, 2023 4 | */ 5 | package api 6 | 7 | import ( 8 | "errors" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | var ( 14 | ErrLoginWrong = errors.New("invalid login credentials") 15 | ErrNoTable = errors.New("no tables available matching reservation requests") 16 | ErrNetwork = errors.New("unknown network error") 17 | ErrPastDate = errors.New("latest reservation time has passed") 18 | ErrTimeNull = errors.New("times list empty") 19 | ErrNoOffer = errors.New("table is not offered on given date") 20 | ErrNoPayInfo = errors.New("no payment info on account") 21 | ) 22 | 23 | 24 | /* 25 | Name: LoginParam 26 | Type: API Func Input Struct 27 | Purpose: Input parameters for the api function 'Login' 28 | Note: LoginParam is meant to hide login details from the app layer, 29 | but each individual external service has different login requirements. 30 | 31 | Field Requirements for Resy: 32 | - Email: string 33 | - Password: string 34 | 35 | Field Requirements for Opentable: 36 | - FirstName: string 37 | - LastName: string 38 | - Email: string 39 | - Mobile: string, omitting dashes and region indicator(i.e. the +1 for US) 40 | 41 | */ 42 | type LoginParam struct { 43 | FirstName string 44 | LastName string 45 | Mobile string 46 | Email string 47 | Password string 48 | } 49 | 50 | /* 51 | Name: LoginResponse 52 | Type: API Func Output Struct 53 | Purpose: Output information for the api function 'Login' 54 | Note: LoginResponse is only meant to be used as an input to the 'Reserve' api function, 55 | and its internals are subject to change with any update, so no code should be written on 56 | another layer relying on the fields of this data structure 57 | */ 58 | type LoginResponse struct { 59 | ID int64 60 | FirstName string 61 | LastName string 62 | Mobile string 63 | Email string 64 | PaymentMethodID int64 65 | AuthToken string 66 | } 67 | 68 | /* 69 | Name: SeachParam 70 | Type: API Func Input Struct 71 | Purpose: Input information to the 'Search' api function 72 | */ 73 | type SearchParam struct { 74 | Name string 75 | Limit int 76 | } 77 | 78 | /* 79 | Name: SeachResponse 80 | Type: API Func Output Struct 81 | Purpose: Output information from 'Search' api function 82 | */ 83 | type SearchResponse struct { 84 | Results []SearchResult 85 | } 86 | 87 | /* 88 | Name: SeachResult 89 | Type: API Output Struct 90 | Purpose: Output specific results from 'Search' api function 91 | */ 92 | type SearchResult struct { 93 | VenueID int64 94 | Name string 95 | Region string 96 | Locality string 97 | Neighborhood string 98 | } 99 | 100 | /* 101 | Name: TableType 102 | Type: API Input Struct 103 | Purpose: Allow an opaque interface for choosing table/seating type 104 | */ 105 | type TableType string 106 | 107 | const ( 108 | DiningRoom TableType = "dining" 109 | Indoor = "indoor" 110 | Outdoor = "outdoor" 111 | Patio = "patio" 112 | Bar = "bar" 113 | Lounge = "lounge" 114 | Booth = "booth" 115 | ) 116 | 117 | /* 118 | Name: ReserveParam 119 | Type: API Func Input Struct 120 | Purpose: Input information to the 'Reserve' api function 121 | */ 122 | type ReserveParam struct { 123 | VenueID int64 124 | ReservationTimes []time.Time 125 | PartySize int 126 | TableTypes []TableType 127 | LoginResp LoginResponse 128 | } 129 | 130 | /* 131 | Name: ReserveResponse 132 | Type: API Func Output Struct 133 | Purpose: Output information from the 'Reserve' api function 134 | */ 135 | type ReserveResponse struct { 136 | ReservationTime time.Time 137 | } 138 | 139 | /* 140 | Name: API 141 | Type: Interface 142 | Purpose: Provide a minimal enough abstraction of common behavior 143 | among external reservation services to allow cross-platform 144 | application production 145 | */ 146 | type API interface { 147 | Login(params LoginParam) (*LoginResponse, error) 148 | Search(params SearchParam) (*SearchResponse, error) 149 | Reserve(params ReserveParam) (*ReserveResponse, error) 150 | AuthMinExpire() (time.Duration) 151 | } 152 | 153 | /* 154 | Name: SearchResponse.ToString 155 | Type: Stringify Func 156 | Purpose: Provide a default string representation of search 157 | responses amongst consumers of this layer 158 | */ 159 | func (sr *SearchResponse) ToString() (string) { 160 | respStr := "\nResponses:" 161 | for _, e := range sr.Results { 162 | respStr += "\n" 163 | respStr += "\tName: " + e.Name + "\n" 164 | respStr += "\t\tVenueID: " + strconv.FormatInt(e.VenueID, 10) + "\n" 165 | respStr += "\t\tRegion: " + e.Region + "\n" 166 | respStr += "\t\tLocality: " + e.Locality + "\n" 167 | respStr += "\t\tNeighborhood: " + e.Neighborhood +"\n" 168 | } 169 | return respStr 170 | } 171 | -------------------------------------------------------------------------------- /api/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Author: Bruce Jagid 3 | Created On: Aug 12, 2023 4 | 5 | ********************************************************************** 6 | 7 | General Purpose: 8 | 9 | The api pkg is the abstraction layer for interacting with 10 | external reservation services, such as resy and opentable. 11 | All services implementing the API interface 12 | are sub-pkgs of api 13 | 14 | ********************************************************************** 15 | 16 | API: 17 | 18 | The API interface specifies 3 methods: 19 | 20 | Login(params LoginParam) (*LoginResponse, error) 21 | Reserve(params ReserveParam) (*ReserveResponse, error) 22 | Search(params SearchParam) (*SearchResponse, error) 23 | 24 | ********************************************************************** 25 | 26 | Login: 27 | 28 | The Login function takes in a set of login credentials and returns 29 | a response. The login credentials vary by external service, with 30 | each defining its own set of necessary fields in each sub-pkg. 31 | The output of the Login function is a LoginResponse, which should 32 | be used as a token in the input params to a Reserve function 33 | call. Login should always be used before a set of reservation 34 | calls and is only used for the purpose of making reservations. 35 | 36 | ********************************************************************** 37 | 38 | Reserve: 39 | 40 | The Reserve function takes in a set of reserve parameters which 41 | specify the date and a priority list of times to try and reserve 42 | at and produces a response indicating the time made or an error. 43 | This function's input parameters specifies a 'LoginResp' which 44 | must be obtained by a 'Login' api function call, though such a 45 | value only needs to be obtained before a series of Reserve calls. 46 | 47 | ********************************************************************** 48 | 49 | Search: 50 | 51 | The Search function takes in a set of query parameters which 52 | specify the name of a restaurant and limit on responses and 53 | produces a response with a slice of search results. These results 54 | contain necessary and helpful data both for identifying the 55 | intended restaurant to reserve at and also for making a 56 | reservation request. 57 | 58 | ********************************************************************** 59 | 60 | AuthMinExpire: 61 | 62 | The AuthMinExpire function provides the minimum time irresepective 63 | of time zone that a login token from the Login function is valid. 64 | This function returns a constant value. If a null value is 65 | returned, the login token is valid indefinitely. 66 | 67 | ********************************************************************** 68 | 69 | */ 70 | package api 71 | -------------------------------------------------------------------------------- /api/opentable/api.go: -------------------------------------------------------------------------------- 1 | package opentable 2 | 3 | import ( 4 | "github.com/21Bruce/resolved-server/api" 5 | "bytes" 6 | "net/http" 7 | "io" 8 | "encoding/json" 9 | "strconv" 10 | "errors" 11 | ) 12 | 13 | var ( 14 | ErrTimeFalse = errors.New("time does not work") 15 | ErrBadData = errors.New("venue id does not work") 16 | ) 17 | 18 | type API struct { 19 | XCSRFToken string 20 | SearchKey string 21 | FindKey string 22 | } 23 | 24 | func GetDefaultAPI() (API) { 25 | return API{ 26 | XCSRFToken: "2b167092-25e4-4f0d-a4a5-6f51e18d24e3", 27 | SearchKey: "3cabca79abcb0db395d3cbebb4d47d41f3ddd69442eba3a57f76b943cceb8cf4", 28 | FindKey: "e6b87021ed6e865a7778aa39d35d09864c1be29c683c707602dd3de43c854d86", 29 | } 30 | } 31 | 32 | func isCodeFail(code int) bool { 33 | fst := code / 100 34 | return (fst != 2) 35 | } 36 | 37 | // Since we don't need to login to opentable to reserve, just store 'login' 38 | // values in the LoginResp, which goes into the reserve function 39 | func (a *API) Login(params api.LoginParam) (*api.LoginResponse, error) { 40 | return &api.LoginResponse{ 41 | FirstName: params.FirstName, 42 | LastName: params.LastName, 43 | Email: params.Email, 44 | Mobile: params.Mobile, 45 | }, nil 46 | } 47 | 48 | func (a *API) getSlotMetadata(params api.ReserveParam, time api.Time) (*string, *string, error) { 49 | findUrl := "https://www.opentable.com/dapi/fe/gql?optype=query&opname=RestaurantsAvailability" 50 | dateStr := params.Year + "-" + params.Month + "-" + params.Day 51 | timeStr := time.Hour + ":" + time.Minute 52 | venueId := strconv.FormatInt(params.VenueID, 10) 53 | partySize := strconv.Itoa(params.PartySize) 54 | variableStr := `"variables": {"onlyPop": false, "forwardDays": 0,` + 55 | `"requireTimes": false, "requireTypes": [], "restaurantIds": [` + venueId + `],` + 56 | `"date":"` + dateStr + `", "time":"` + timeStr + `", "partySize":` + partySize + 57 | `,"databaseRegion": "NA"}` 58 | extensionStr := `"extensions": {"persistedQuery": {"version": 1, "sha256Hash": "` + 59 | a.FindKey + `"}}` 60 | bodyStr := `{"operationName": "RestaurantsAvailability", ` + variableStr + `, `+ extensionStr + `}` 61 | bodyBytes := []byte(bodyStr) 62 | request, err := http.NewRequest("POST", findUrl, bytes.NewBuffer(bodyBytes)) 63 | 64 | if err != nil { 65 | return nil, nil, err 66 | } 67 | 68 | request.Header.Set("Content-Type", "application/json") 69 | request.Header.Set("Host", "www.opentable.com") 70 | request.Header.Set("x-csrf-token", a.XCSRFToken) 71 | request.Header.Set("accept", "*/*") 72 | request.Header.Set("Connection", "keep-alive") 73 | request.Header.Set("Origin", "https://www.opentable.com") 74 | request.Header.Set("user-agent", "Resolved-Server") 75 | 76 | client := &http.Client{} 77 | 78 | response, err := client.Do(request) 79 | 80 | if err != nil { 81 | return nil, nil, err 82 | } 83 | 84 | if isCodeFail(response.StatusCode) { 85 | return nil, nil, api.ErrNetwork 86 | } 87 | 88 | defer response.Body.Close() 89 | 90 | responseBody, err := io.ReadAll(response.Body) 91 | if err != nil { 92 | return nil, nil, err 93 | } 94 | 95 | var jsonTopLevelMap map[string]interface{} 96 | err = json.Unmarshal(responseBody, &jsonTopLevelMap) 97 | if err != nil { 98 | return nil, nil, err 99 | } 100 | 101 | jsonDataMap := jsonTopLevelMap["data"].(map[string]interface{}) 102 | if jsonDataMap["availability"] == nil { 103 | return nil, nil, ErrBadData 104 | } 105 | 106 | jsonAvailabilityMap := jsonDataMap["availability"].([]interface{})[0].(map[string]interface{}) 107 | jsonAvailabilityDaysMap := jsonAvailabilityMap["availabilityDays"].([]interface{})[0].(map[string]interface{}) 108 | jsonHitsMap := jsonAvailabilityDaysMap["slots"].([]interface{}) 109 | for i := 0; i < len(jsonHitsMap); i++ { 110 | jsonHitMap := jsonHitsMap[i].(map[string]interface{}) 111 | if jsonHitMap["isAvailable"].(bool) != true { 112 | continue 113 | } 114 | if int(jsonHitMap["timeOffsetMinutes"].(float64)) != 0 { 115 | continue 116 | } 117 | slotHash := jsonHitMap["slotHash"].(string) 118 | slotToken := jsonHitMap["slotAvailabilityToken"].(string) 119 | return &slotHash, &slotToken, nil 120 | 121 | } 122 | return nil, nil, api.ErrNoTable 123 | 124 | } 125 | 126 | func (a *API) finalizeReservation(hash string, token string, time api.Time, params api.ReserveParam) (*api.ReserveResponse, error) { 127 | resUrl := "https://www.opentable.com/dapi/booking/make-reservation" 128 | dateStr := params.Year + "-" + params.Month + "-" + params.Day 129 | timeStr := time.Hour + ":" + time.Minute 130 | dateTimeStr := dateStr + "T" + timeStr 131 | venueId := strconv.FormatInt(params.VenueID, 10) 132 | partySize := strconv.Itoa(params.PartySize) 133 | bodyStr := `{"restaurantId": ` + venueId + `,` + 134 | `"slotAvailabilityToken": "` + token + `",` + 135 | `"slotHash": "` + hash + `",` + 136 | `"isModify": false,` + 137 | `"reservationDateTime": "` + dateTimeStr + `",` + 138 | `"partySize": ` + partySize + `,` + 139 | `"firstName": "` + params.LoginResp.FirstName + `",` + 140 | `"lastName": "` + params.LoginResp.LastName + `",` + 141 | `"email": "` + params.LoginResp.Email + `",` + 142 | `"phoneNumber": "` + params.LoginResp.Mobile + `",` + 143 | `"phoneNumberCountryId": "US",` + 144 | `"country": "US",` + 145 | `"reservationType": "Standard",` + 146 | `"reservationAttribute": "highTop",` + 147 | `"pointsType": "Standard",` + 148 | `"diningAreaId": 1,` + 149 | `"optInEmailRestaurant": false}` 150 | bodyBytes := []byte(bodyStr) 151 | request, err := http.NewRequest("POST", resUrl, bytes.NewBuffer(bodyBytes)) 152 | 153 | if err != nil { 154 | return nil, err 155 | } 156 | 157 | request.Header.Set("Content-Type", "application/json") 158 | request.Header.Set("Host", "www.opentable.com") 159 | request.Header.Set("x-csrf-token", a.XCSRFToken) 160 | request.Header.Set("accept", "*/*") 161 | request.Header.Set("Connection", "keep-alive") 162 | request.Header.Set("Origin", "https://www.opentable.com") 163 | request.Header.Set("user-agent", "Resolved-Server") 164 | 165 | client := &http.Client{} 166 | 167 | response, err := client.Do(request) 168 | 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | if isCodeFail(response.StatusCode) { 174 | return nil, api.ErrNetwork 175 | } 176 | 177 | defer response.Body.Close() 178 | 179 | responseBody, err := io.ReadAll(response.Body) 180 | if err != nil { 181 | return nil, err 182 | } 183 | 184 | var jsonTopLevelMap map[string]interface{} 185 | err = json.Unmarshal(responseBody, &jsonTopLevelMap) 186 | 187 | if err != nil { 188 | return nil, err 189 | } 190 | 191 | if jsonTopLevelMap["success"].(bool) { 192 | return &api.ReserveResponse{ 193 | ReservationTime: time, 194 | }, nil 195 | } 196 | 197 | return nil, api.ErrNoTable 198 | 199 | 200 | } 201 | 202 | func (a *API) Reserve(params api.ReserveParam) (*api.ReserveResponse, error) { 203 | for _, v := range params.ReservationTimes { 204 | hash, token, err := a.getSlotMetadata(params, v) 205 | if err != nil { 206 | continue 207 | } 208 | res, err := a.finalizeReservation(*hash, *token, v, params) 209 | if err != nil { 210 | continue 211 | } 212 | return res, nil 213 | } 214 | return nil, api.ErrNoTable 215 | } 216 | 217 | func (a *API) Search(params api.SearchParam) (*api.SearchResponse, error) { 218 | searchUrl := "https://www.opentable.com/dapi/fe/gql?optype=query&opname=Autocomplete" 219 | 220 | variableStr := `"variables": {"term": "` + params.Name +`", "latitude": 1, "longitude": 1, "useNewVersion": true}` 221 | extensionStr := `"extensions": {"persistedQuery": {"version": 1, "sha256Hash":"` + a.SearchKey + `"}}` 222 | bodyStr :=`{"operationName": "Autocomplete",` + variableStr + `,` + extensionStr + `}` 223 | bodyBytes := []byte(bodyStr) 224 | 225 | request, err := http.NewRequest("POST", searchUrl, bytes.NewBuffer(bodyBytes)) 226 | 227 | if err != nil { 228 | return nil, err 229 | } 230 | 231 | request.Header.Set("Content-Type", "application/json") 232 | request.Header.Set("Host", "www.opentable.com") 233 | request.Header.Set("x-csrf-token", a.XCSRFToken) 234 | request.Header.Set("accept", "*/*") 235 | request.Header.Set("Connection", "keep-alive") 236 | request.Header.Set("Origin", "https://www.opentable.com") 237 | request.Header.Set("user-agent", "Resolved-Server") 238 | 239 | client := &http.Client{} 240 | 241 | response, err := client.Do(request) 242 | 243 | if err != nil { 244 | return nil, err 245 | } 246 | 247 | if isCodeFail(response.StatusCode) { 248 | return nil, api.ErrNetwork 249 | } 250 | 251 | defer response.Body.Close() 252 | 253 | responseBody, err := io.ReadAll(response.Body) 254 | if err != nil { 255 | return nil, err 256 | } 257 | 258 | var jsonTopLevelMap map[string]interface{} 259 | err = json.Unmarshal(responseBody, &jsonTopLevelMap) 260 | if err != nil { 261 | return nil, err 262 | } 263 | 264 | jsonDataMap := jsonTopLevelMap["data"].(map[string]interface{}) 265 | jsonAutocompleteMap := jsonDataMap["autocomplete"].(map[string]interface{}) 266 | jsonResultsMap := jsonAutocompleteMap["autocompleteResults"].([]interface{}) 267 | numPossibleHits := len(jsonResultsMap) 268 | var limit int 269 | if params.Limit > 0 { 270 | limit = params.Limit 271 | } else { 272 | limit = numPossibleHits 273 | } 274 | 275 | searchResults := make([]api.SearchResult, 0, limit) 276 | for i:=0; i 0 { 212 | limit = min(params.Limit, numHits) 213 | } else { 214 | limit = numHits 215 | } 216 | searchResults := make([]api.SearchResult, limit, limit) 217 | for i:=0; i 2 && string(token[0:2]) == "--" { 171 | // check if longname exists 172 | didFnd := false 173 | for _, flag := range cmd.Flags { 174 | if flag.LongName != "" && flag.LongName == string(token[2:]) { 175 | if out[flag.Name] != nil { 176 | return "", ErrRpFlg 177 | } 178 | currFlg = flag.Name 179 | out[currFlg] = make([]string, 0) 180 | didFnd = true 181 | break 182 | } 183 | } 184 | if didFnd { 185 | continue 186 | } 187 | } else if len(token) > 1 && string(token[0]) == "-"{ 188 | didFnd := false 189 | for _, flag := range cmd.Flags { 190 | if flag.Name == string(token[1:]) { 191 | if out[flag.Name] != nil { 192 | return "", ErrRpFlg 193 | } 194 | currFlg = flag.Name 195 | out[currFlg] = make([]string, 0) 196 | didFnd = true 197 | break 198 | } 199 | } 200 | if didFnd { 201 | continue 202 | } 203 | } 204 | if currFlg == "" { 205 | return "", ErrNoFlg 206 | } 207 | out[currFlg] = append(out[currFlg], token) 208 | } 209 | 210 | // perform validation 211 | err := pc.validation(cmd, out) 212 | 213 | if err != nil { 214 | return "", err 215 | } 216 | 217 | return cmd.Handler(out) 218 | } 219 | 220 | 221 | /* 222 | Name: validation 223 | Type: Internal CLI func 224 | Purpose: Parse logic for flags 225 | */ 226 | func (pc *ParseCtx) validation(cmd Command, in map[string][]string) (error){ 227 | for _, flag := range cmd.Flags { 228 | if flag.ValidationCtx.Required && in[flag.Name] == nil { 229 | return ErrMissReq 230 | } 231 | if in[flag.Name] != nil { 232 | if flag.ValidationCtx.MaxArgs != InfiniteArgs && len(in[flag.Name]) > flag.ValidationCtx.MaxArgs { 233 | return ErrMulArg 234 | } 235 | if len(in[flag.Name]) < flag.ValidationCtx.MinArgs { 236 | return ErrNoArg 237 | } 238 | } 239 | } 240 | return nil 241 | } 242 | 243 | /* 244 | Name: Parse 245 | Type: External CLI func 246 | Purpose: Parse input str 247 | */ 248 | func (pc *ParseCtx) Parse(in string) (string, error) { 249 | tokens, err := pc.Tokenize(in) 250 | 251 | if err != nil { 252 | return "", err 253 | } 254 | 255 | if len(tokens) == 0 { 256 | return "", ErrNoCmd 257 | } 258 | 259 | for _, cmd := range pc.Commands { 260 | if cmd.Name == tokens[0] { 261 | return pc.parseFlags(cmd, tokens[1:]) 262 | } 263 | } 264 | 265 | return "", ErrNoCmd 266 | } 267 | 268 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/21Bruce/resolved-server 2 | 3 | go 1.20 4 | 5 | require github.com/gorilla/mux v1.8.0 // indirect 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 2 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 3 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Author: Bruce Jagid 3 | Created On: Aug 12, 2023 4 | */ 5 | package main 6 | 7 | import ( 8 | "github.com/21Bruce/resolved-server/api/resy" 9 | "github.com/21Bruce/resolved-server/app" 10 | "github.com/21Bruce/resolved-server/runnable/cli" 11 | "os" 12 | ) 13 | 14 | func main() { 15 | resy_api := resy.GetDefaultAPI() 16 | appCtx := app.AppCtx{API: &resy_api} 17 | cli := cli.ResolvedCLI{ 18 | AppCtx: appCtx, 19 | In: os.Stdin, 20 | Out: os.Stdout, 21 | Err: os.Stderr, 22 | } 23 | cli.Run() 24 | } 25 | -------------------------------------------------------------------------------- /runnable/cli/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Author: Bruce Jagid 3 | Created On: Aug 12, 2023 4 | 5 | ********************************************************************** 6 | 7 | General Purpose: 8 | 9 | The runnable/cli pkg is a complete program representing the combo 10 | of the core app with a command line interface. This cli is a REPL, 11 | and acts as a running environment which is not supposed to crash. 12 | 13 | This pkg has two very big dependencies: The app pkg(back-end) and 14 | the cli pkg(front-end), so understanding internals can be learned 15 | best from those studying those pkgs. We provide only an 16 | explanation of the public facing app here. 17 | 18 | ********************************************************************** 19 | 20 | How To Use(In Code): 21 | 22 | The functionality in the runnable/cli pkg can be used via 23 | obtaining a ResolvedCLI struct. This struct requires a few 24 | fields. It takes in an io.Reader object to read input from 25 | in the In field, and io.Writer objects for the Out and Err 26 | fields, where it will report valid output or errors respectively. 27 | Finally, the Resolved CLI takes in an AppCtx, with the intent 28 | being that this CLI pkg can be easily repurposed between external 29 | APIs. Although the opentable go API is not complete yet, its 30 | partial development has already yielded difficulties for this pkg. 31 | 32 | ********************************************************************** 33 | 34 | How to Use(On Computer): 35 | 36 | The ResolvedCLI allows an end-user to access all of the core 37 | app features for the Resy(Opentable soon) implementation 38 | via a simple CLI. We explain each command here: 39 | 40 | 1. login [-e email] [-p password] 41 | 42 | This command checks if the given 43 | login information link to a valid Resy 44 | account and if they do will save them 45 | for use in future commands 46 | 47 | 2. logout 48 | 49 | This command removes any saved login 50 | info if present 51 | 52 | 3. search [-n name] [-l limit] 53 | 54 | This command searches resy for the 55 | given name in the -n field and will 56 | limit the return results with the -l 57 | field, although this field is optional. 58 | The resy API returns valuable information 59 | about each found result such as locality 60 | and region information so an end-user can 61 | verify that the returned restaurant is 62 | correct and the API yields the 'VenueID', 63 | an internal identifier used by resy to 64 | specify restaurants and a piece of data 65 | that must be sent in a reservation command 66 | 67 | 4. rats [-v venue-id] [-ps party-size] [-resD reservation-day] [-resT reservation-times] [-reqD request-date] 68 | 69 | This command sends a reservation request 70 | at a specified date down to the minute. 71 | The res is for a venue specified by the id in the 72 | -v field, party size specified by the 73 | -ps field, reservation day specified 74 | in the -resD field(in YYYY:MM:DD format 75 | relative to the restaurant locale), 76 | priority list of reservation times 77 | specified in the -resT field(each in HH:MM 78 | military time format relative to the 79 | restaurant locale), and the date to send 80 | the request to resy in the -reqD field 81 | (in YYYY:MM:DD:HH:MM miliatry time format 82 | relative to the local locale). 83 | 84 | 5. rais [-v venue-id] [-ps party-size] [-resD reservation-day] [-resT reservation-times] [-i interval] 85 | 86 | This command sends a reservation request 87 | on a repeated interval until a time is 88 | acquired or all possible times are past. 89 | The res is for a venue specified by the id in the 90 | -v field, party size specified by the 91 | -ps field, reservation day specified 92 | in the -resD field(in YYYY:MM:DD format 93 | relative to the restaurant locale), 94 | priority list of reservation times 95 | specified in the -resT field(each in HH:MM 96 | military time format relative to the 97 | restaurant locale), and the interval to send 98 | the request to resy in the -i field 99 | (in HH:MM format). 100 | 101 | 6. list 102 | 103 | This command lists a history of operations, their IDs, 104 | and statuses 105 | 106 | 7. cancel [-i id] 107 | 108 | This command will attempt to cancel the operations with 109 | ids specified in the -i field. Operations can only be 110 | cancelled if they are in progress 111 | 112 | 8. clean [-i id] 113 | 114 | This command will attempt to remove the operation 115 | from the history displayed by the list command. This 116 | will only work on operations that are not in progress. 117 | 118 | 9. help 119 | 120 | Display helpful info about commands 121 | 122 | 10. exit/quit 123 | 124 | Leave the CLI environment 125 | 126 | ********************************************************************** 127 | */ 128 | package cli 129 | -------------------------------------------------------------------------------- /runnable/cli/runnable.go: -------------------------------------------------------------------------------- 1 | /* 2 | Author: Bruce Jagid 3 | Created On: Aug 12, 2023 4 | */ 5 | package cli 6 | 7 | import ( 8 | "bufio" 9 | "io" 10 | "fmt" 11 | "strconv" 12 | "strings" 13 | "github.com/21Bruce/resolved-server/app" 14 | "github.com/21Bruce/resolved-server/api" 15 | "github.com/21Bruce/resolved-server/cli" 16 | "os" 17 | "errors" 18 | "time" 19 | ) 20 | 21 | var ( 22 | // Error if we can't parse date properly 23 | ErrInvDate = errors.New("invalid date format") 24 | // Error if we can't parse table type properly 25 | ErrInvTableType = errors.New("invalid table type") 26 | ) 27 | 28 | /* 29 | Name: ResolvedCLI 30 | Type: External CLI Struct 31 | Purpose: Encapsulate the state 32 | and initial config of the system 33 | */ 34 | type ResolvedCLI struct { 35 | AppCtx app.AppCtx 36 | In io.Reader 37 | Out io.Writer 38 | Err io.Writer 39 | parseCtx cli.ParseCtx 40 | } 41 | 42 | /* 43 | Name: parseSearch 44 | Type: Internal Func 45 | Purpose: Perform some extra 46 | parsing on top of the cli parser 47 | and return the field for use in the main 48 | handler 49 | */ 50 | func parseSearch(in map[string][]string) (string, int, error){ 51 | 52 | name := in["n"][0] 53 | limit := 0 54 | if in["l"] != nil { 55 | limitRes, err := strconv.Atoi(in["l"][0]) 56 | if err != nil { 57 | return "", 0 , err 58 | } 59 | limit = limitRes 60 | } 61 | return name, limit, nil 62 | 63 | } 64 | 65 | /* 66 | Name: handleSearch 67 | Type: Internal Func 68 | Purpose: This function 69 | is the handler for the 'search' command. 70 | It is responsible for taking in the validated 71 | flag args and returning a string 72 | of the search results 73 | */ 74 | func (c *ResolvedCLI) handleSearch(in map[string][]string) (string, error) { 75 | name, limit, err := parseSearch(in) 76 | if err != nil { 77 | return "", err 78 | } 79 | searchParams := app.SearchParam{Name: name, Limit: limit} 80 | resp, err := c.AppCtx.Search(searchParams) 81 | if err != nil { 82 | return "", err 83 | } 84 | retVal := api.SearchResponse(*resp) 85 | return retVal.ToString(), nil 86 | } 87 | 88 | /* 89 | Name: handleQuit 90 | Type: Internal Func 91 | Purpose: This function 92 | is the handler for the 'exit' and 'quit' commands. 93 | It is responsible for exiting the CLI 94 | */ 95 | func (c *ResolvedCLI) handleQuit(in map[string][]string) (string, error) { 96 | os.Exit(0) 97 | return "", nil 98 | } 99 | 100 | /* 101 | Name: flagToShortStr 102 | Type: Internal Func 103 | Purpose: This function assists in 104 | the 'help' command, this all should be moved 105 | to the cli top level 106 | */ 107 | func flagToShortStr(flag cli.Flag) (string) { 108 | flagStr := " [-" + flag.Name 109 | if flag.LongName != "" { 110 | flagStr += "|--" + flag.LongName 111 | } 112 | flagStr += "]" 113 | return flagStr 114 | } 115 | 116 | /* 117 | Name: handleHelp 118 | Type: Internal Func 119 | Purpose: This function is the handler 120 | for the 'help' command, It is responsible 121 | for printing out helpful info for each 122 | command, it really should be moved to the 123 | cli top level 124 | */ 125 | func (c *ResolvedCLI) handleHelp(in map[string][]string) (string, error) { 126 | helpStr := "Commands: \n" 127 | for _, cmd := range c.parseCtx.Commands { 128 | helpStr += "\t" + cmd.Name 129 | for _, flag := range cmd.Flags { 130 | helpStr += flagToShortStr(flag) 131 | } 132 | helpStr += ": "+ cmd.Description + "\n" 133 | for _, flag := range cmd.Flags { 134 | helpStr += "\t\t" + flagToShortStr(flag) + ": " + flag.Description + "\n" 135 | } 136 | } 137 | 138 | return helpStr, nil 139 | } 140 | 141 | /* 142 | Name: handleList 143 | Type: Internal Func 144 | Purpose: This function is the handler 145 | for the 'list' command, It is responsible 146 | for printing out a history of operations 147 | from the AppCtx 148 | */ 149 | func (c *ResolvedCLI) handleList(in map[string][]string) (string, error) { 150 | return c.AppCtx.OperationsToString() 151 | } 152 | 153 | /* 154 | Name: parseRats 155 | Type: Internal Func 156 | Purpose: This function helps with parsing 157 | for the main 'rats' handler function 158 | */ 159 | func (c *ResolvedCLI) parseRats(in map[string][]string) (*app.ReserveAtTimeParam, error) { 160 | req := app.ReserveAtTimeParam{} 161 | // if we have login info, overwrite the default 162 | if in["e"] != nil { 163 | req.Login.Email = in["e"][0] 164 | } 165 | if in["p"] != nil { 166 | req.Login.Password = in["p"][0] 167 | } 168 | if in["t"] != nil { 169 | req.TableTypes = make([]api.TableType, len(in["t"]), len(in["t"])) 170 | for i := 0; i < len(in["t"]); i++ { 171 | currType := strings.ToLower(in["t"][i]) 172 | if strings.Contains(currType, string(api.DiningRoom)) { 173 | req.TableTypes[i] = api.DiningRoom 174 | } else if strings.Contains(currType, string(api.Indoor)) { 175 | req.TableTypes[i] = api.Indoor 176 | } else if strings.Contains(currType, string(api.Outdoor)) { 177 | req.TableTypes[i] = api.Outdoor 178 | } else if strings.Contains(currType, string(api.Patio)) { 179 | req.TableTypes[i] = api.Patio 180 | } else if strings.Contains(currType, string(api.Bar)) { 181 | req.TableTypes[i] = api.Bar 182 | } else if strings.Contains(currType, string(api.Lounge)) { 183 | req.TableTypes[i] = api.Lounge 184 | } else if strings.Contains(currType, string(api.Booth)) { 185 | req.TableTypes[i] = api.Booth 186 | } else { 187 | return nil, ErrInvTableType 188 | } 189 | } 190 | } 191 | id, err := strconv.ParseInt(in["v"][0], 10, 64) 192 | if err != nil { 193 | return nil, err 194 | } 195 | req.VenueID = id 196 | rawResDay := in["resD"][0] 197 | resDaySplt := strings.Split(rawResDay, ":") 198 | if len(resDaySplt) != 3 { 199 | return nil, ErrInvDate 200 | } 201 | 202 | reqYear, err := strconv.Atoi(resDaySplt[0]) 203 | if err != nil { 204 | return nil, err 205 | } 206 | reqMonth, err := strconv.Atoi(resDaySplt[1]) 207 | if err != nil { 208 | return nil, err 209 | } 210 | reqDay, err := strconv.Atoi(resDaySplt[2]) 211 | if err != nil { 212 | return nil, err 213 | } 214 | 215 | req.ReservationTimes = make([]time.Time, len(in["resT"]), len(in["resT"])) 216 | for i, timeStr := range in["resT"] { 217 | timeSplt := strings.Split(timeStr, ":") 218 | if len(timeSplt) != 2 { 219 | return nil, ErrInvDate 220 | } 221 | reqHour, err := strconv.Atoi(timeSplt[0]) 222 | if err != nil { 223 | return nil, err 224 | } 225 | reqMin, err := strconv.Atoi(timeSplt[1]) 226 | if err != nil { 227 | return nil, err 228 | } 229 | req.ReservationTimes[i] = time.Date(reqYear, time.Month(reqMonth), reqDay, reqHour, reqMin, 0, 0, time.Local) 230 | 231 | } 232 | ps, err := strconv.ParseInt(in["ps"][0], 10, 64) 233 | if err != nil { 234 | return nil, err 235 | } 236 | req.PartySize = int(ps) 237 | rawReqDate := in["reqD"][0] 238 | reqDateSplt := strings.Split(rawReqDate, ":") 239 | 240 | if len(reqDateSplt) != 5 { 241 | return nil, ErrInvDate 242 | } 243 | 244 | year, err := strconv.ParseInt(reqDateSplt[0], 10, 64) 245 | if err != nil { 246 | return nil, err 247 | } 248 | month, err := strconv.ParseInt(reqDateSplt[1], 10, 64) 249 | if err != nil { 250 | return nil, err 251 | } 252 | day, err := strconv.ParseInt(reqDateSplt[2], 10, 64) 253 | if err != nil { 254 | return nil, err 255 | } 256 | hour, err := strconv.ParseInt(reqDateSplt[3], 10, 64) 257 | if err != nil { 258 | return nil, err 259 | } 260 | minute, err := strconv.ParseInt(reqDateSplt[4], 10, 64) 261 | if err != nil { 262 | return nil, err 263 | } 264 | timeLoc := time.Date(int(year), time.Month(int(month)), int(day), int(hour), int(minute), 0, 0, time.Local) 265 | timeUTC := timeLoc.UTC() 266 | req.RequestTime = timeUTC 267 | return &req, nil 268 | } 269 | 270 | /* 271 | Name: handleRats 272 | Type: Internal Func 273 | Purpose: This function is the handler 274 | for the 'rats' command. It's goal is to 275 | take the values defined in each flag field 276 | and schedule a reserve at time operation 277 | in the AppCtx 278 | */ 279 | func (c *ResolvedCLI) handleRats(in map[string][]string) (string, error) { 280 | req, err := c.parseRats(in) 281 | if err != nil { 282 | return "", err 283 | } 284 | id, err := c.AppCtx.ScheduleReserveAtTimeOperation(*req) 285 | if err != nil { 286 | return "", err 287 | } 288 | idstr := strconv.FormatInt(id, 10) 289 | // if successful, tell user which id the operation is linked to 290 | retstr := "Successfully started rats operation with ID " + idstr 291 | return retstr, nil 292 | } 293 | 294 | /* 295 | Name: parseRais 296 | Type: Internal Func 297 | Purpose: This function helps with parsing 298 | for the main 'rats' handler function. 299 | This function is very similiar to parseRats 300 | and can probably be merged a little 301 | */ 302 | func (c *ResolvedCLI) parseRais(in map[string][]string) (*app.ReserveAtIntervalParam, error) { 303 | req := app.ReserveAtIntervalParam{} 304 | if in["e"] != nil { 305 | req.Login.Email = in["e"][0] 306 | } 307 | if in["p"] != nil { 308 | req.Login.Password = in["p"][0] 309 | } 310 | if in["t"] != nil { 311 | req.TableTypes = make([]api.TableType, len(in["t"]), len(in["t"])) 312 | for i := 0; i < len(in["t"]); i++ { 313 | currType := in["t"][i] 314 | if strings.Contains(currType, string(api.DiningRoom)) { 315 | req.TableTypes[i] = api.DiningRoom 316 | } else if strings.Contains(currType, string(api.Indoor)) { 317 | req.TableTypes[i] = api.Indoor 318 | } else if strings.Contains(currType, string(api.Outdoor)) { 319 | req.TableTypes[i] = api.Outdoor 320 | } else if strings.Contains(currType, string(api.Patio)) { 321 | req.TableTypes[i] = api.Patio 322 | } else if strings.Contains(currType, string(api.Bar)) { 323 | req.TableTypes[i] = api.Bar 324 | } else if strings.Contains(currType, string(api.Lounge)) { 325 | req.TableTypes[i] = api.Lounge 326 | } else if strings.Contains(currType, string(api.Booth)) { 327 | req.TableTypes[i] = api.Booth 328 | } else { 329 | return nil, ErrInvTableType 330 | } 331 | } 332 | } 333 | id, err := strconv.ParseInt(in["v"][0], 10, 64) 334 | if err != nil { 335 | return nil, err 336 | } 337 | req.VenueID = id 338 | rawResDay := in["resD"][0] 339 | resDaySplt := strings.Split(rawResDay, ":") 340 | if len(resDaySplt) != 3 { 341 | return nil, ErrInvDate 342 | } 343 | reqYear, err := strconv.Atoi(resDaySplt[0]) 344 | if err != nil { 345 | return nil, err 346 | } 347 | reqMonth, err := strconv.Atoi(resDaySplt[1]) 348 | if err != nil { 349 | return nil, err 350 | } 351 | reqDay, err := strconv.Atoi(resDaySplt[2]) 352 | if err != nil { 353 | return nil, err 354 | } 355 | req.ReservationTimes = make([]time.Time, len(in["resT"]), len(in["resT"])) 356 | for i, timeStr := range in["resT"] { 357 | timeSplt := strings.Split(timeStr, ":") 358 | if len(timeSplt) != 2 { 359 | return nil, ErrInvDate 360 | } 361 | reqHour, err := strconv.Atoi(timeSplt[0]) 362 | if err != nil { 363 | return nil, err 364 | } 365 | reqMin, err := strconv.Atoi(timeSplt[1]) 366 | if err != nil { 367 | return nil, err 368 | } 369 | req.ReservationTimes[i] = time.Date(reqYear, time.Month(reqMonth), reqDay, reqHour, reqMin, 0, 0, time.Local) 370 | } 371 | ps, err := strconv.ParseInt(in["ps"][0], 10, 64) 372 | if err != nil { 373 | return nil, err 374 | } 375 | req.PartySize = int(ps) 376 | rawRepInt := in["i"][0] 377 | repIntSplt := strings.Split(rawRepInt, ":") 378 | 379 | if len(repIntSplt) != 2 { 380 | return nil, ErrInvDate 381 | } 382 | 383 | repHour, err := strconv.Atoi(repIntSplt[0]) 384 | if err != nil { 385 | return nil, err 386 | } 387 | repMin, err := strconv.Atoi(repIntSplt[1]) 388 | if err != nil { 389 | return nil, err 390 | } 391 | req.RepeatInterval = time.Hour * time.Duration(repHour) + time.Minute * time.Duration(repMin) 392 | 393 | return &req, nil 394 | } 395 | 396 | /* 397 | Name: handleRais 398 | Type: Internal Func 399 | Purpose: This function is the handler 400 | for the 'rais' command. Its goal is to 401 | take the values defined in each flag field 402 | and schedule a reserve at interval operation 403 | in the AppCtx 404 | */ 405 | func (c *ResolvedCLI) handleRais(in map[string][]string) (string, error) { 406 | req, err := c.parseRais(in) 407 | if err != nil { 408 | return "", err 409 | } 410 | id, err := c.AppCtx.ScheduleReserveAtIntervalOperation(*req) 411 | if err != nil { 412 | return "", err 413 | } 414 | idstr := strconv.FormatInt(id, 10) 415 | // if successful, tell user and print ID of new operation 416 | retstr := "Successfully started rais operation with ID " + idstr 417 | return retstr, nil 418 | } 419 | 420 | /* 421 | Name: handleLogin 422 | Type: Internal Func 423 | Purpose: This function is the handler 424 | for the 'login' command, its goal is to 425 | save the login info on the appctx if its 426 | valid 427 | */ 428 | func (c *ResolvedCLI) handleLogin(in map[string][]string) (string, error) { 429 | req := app.LoginParam{ 430 | Email: in["e"][0], 431 | Password: in["p"][0], 432 | } 433 | err := c.AppCtx.Login(req) 434 | if err != nil { 435 | return "", err 436 | } 437 | // if successful, tell user 438 | return "Successfully Logged In", nil 439 | } 440 | 441 | /* 442 | Name: handleLogout 443 | Type: Internal Func 444 | Purpose: This function is the handler 445 | for the 'logout' command, its goal is to 446 | erase login info from the appctx 447 | */ 448 | func (c *ResolvedCLI) handleLogout(in map[string][]string) (string, error) { 449 | err := c.AppCtx.Logout() 450 | if err != nil { 451 | return "", err 452 | } 453 | // if successful, tell user 454 | return "Successfully Logged Out", nil 455 | } 456 | 457 | /* 458 | Name: handleCancel 459 | Type: Internal Func 460 | Purpose: This function is the handler 461 | for the 'cancel' command, its goal is to 462 | cancel all operations given the id list 463 | in the -i field. We only cancel all or no 464 | operations, so we check before if they are 465 | valid to be cancelled 466 | */ 467 | func (c *ResolvedCLI) handleCancel(in map[string][]string) (string, error) { 468 | for _, idStr := range in["i"] { 469 | id, err := strconv.ParseInt(idStr, 10, 64) 470 | if err != nil { 471 | return "", err 472 | } 473 | stat, err := c.AppCtx.OperationStatus(id) 474 | if err != nil { 475 | return "", err 476 | } 477 | if stat != app.InProgressStatusType { 478 | return "", app.ErrFinOp 479 | } 480 | } 481 | for _, idStr := range in["i"] { 482 | // errs checked above 483 | id, _ := strconv.ParseInt(idStr, 10, 64) 484 | c.AppCtx.CancelOperation(id) 485 | } 486 | return "Cancelled Operations Successfully", nil 487 | } 488 | 489 | /* 490 | Name: handleClean 491 | Type: Internal Func 492 | Purpose: This function is the handler 493 | for the 'clean' command, its goal is to 494 | cancel all operations given the id list 495 | in the -i field. We only clean all or no 496 | operations, so we check before if they are 497 | valid to be cleaned 498 | */ 499 | func (c *ResolvedCLI) handleClean(in map[string][]string) (string, error) { 500 | for _, idStr := range in["i"] { 501 | id, err := strconv.ParseInt(idStr, 10, 64) 502 | if err != nil { 503 | return "", err 504 | } 505 | stat, err := c.AppCtx.OperationStatus(id) 506 | if err != nil { 507 | return "", err 508 | } 509 | if stat == app.InProgressStatusType { 510 | return "", app.ErrCurrOp 511 | } 512 | } 513 | for _, idStr := range in["i"] { 514 | // errs checked above 515 | id, _ := strconv.ParseInt(idStr, 10, 64) 516 | c.AppCtx.CleanOperation(id) 517 | } 518 | return "Cleaned Operations Successfully", nil 519 | } 520 | 521 | /* 522 | Name: initParseCtx 523 | Type: Internal Func 524 | Purpose: This function initializes 525 | the parse ctx with the above handlers 526 | and command info 527 | */ 528 | func (c *ResolvedCLI) initParseCtx() { 529 | // 'search' command 530 | searchCommand := cli.Command{ 531 | Name: "search", 532 | Description: "Finds restaurant info", 533 | Flags: []cli.Flag{ 534 | cli.Flag{ 535 | Name: "n", 536 | LongName: "name", 537 | Description: "This flag is required. It takes one text input, the name of the restaurant", 538 | ValidationCtx: cli.FlagValidationCtx{ 539 | Required: true, 540 | MinArgs: 1, 541 | MaxArgs: 1, 542 | }, 543 | }, 544 | cli.Flag{ 545 | Name: "l", 546 | LongName: "limit", 547 | Description: "This flag is optional. It takes one number input, the max amount of results to return", 548 | ValidationCtx: cli.FlagValidationCtx{ 549 | Required: false, 550 | MinArgs: 1, 551 | MaxArgs: 1, 552 | }, 553 | }, 554 | }, 555 | Handler: c.handleSearch, 556 | } 557 | 558 | // 'rats' command 559 | ratsCommand := cli.Command{ 560 | Name: "rats", 561 | Description: "Reserve At Time Scheduler", 562 | Flags: []cli.Flag{ 563 | cli.Flag{ 564 | Name: "e", 565 | LongName: "email", 566 | Description: "This flag is optional if already logged in using Login command. Specifies login email", 567 | ValidationCtx: cli.FlagValidationCtx{ 568 | Required: false, 569 | MinArgs: 1, 570 | MaxArgs: 1, 571 | }, 572 | }, 573 | cli.Flag{ 574 | Name: "p", 575 | LongName: "password", 576 | Description: "This flag is optional if already logged in using Login command. Specifies login password", 577 | ValidationCtx: cli.FlagValidationCtx{ 578 | Required: false, 579 | MinArgs: 1, 580 | MaxArgs: 1, 581 | }, 582 | }, 583 | cli.Flag{ 584 | Name: "v", 585 | LongName: "venue-id", 586 | Description: "This flag is required. Specifies the venueu id(use search to find by name)", 587 | ValidationCtx: cli.FlagValidationCtx{ 588 | Required: true, 589 | MinArgs: 1, 590 | MaxArgs: 1, 591 | }, 592 | }, 593 | cli.Flag{ 594 | Name: "t", 595 | LongName: "table", 596 | Description: "This flag is optional. Used to set the type of table in order of preference. The available types are dining, patio, bar, lounge, indoor, and outdoor", 597 | ValidationCtx: cli.FlagValidationCtx{ 598 | Required: false, 599 | MinArgs: 1, 600 | MaxArgs: cli.InfiniteArgs, 601 | }, 602 | }, 603 | cli.Flag{ 604 | Name: "resD", 605 | LongName: "reservation-day", 606 | Description: "This flag is required. Specifies the day for the reservation in yyyy:mm:dd format", 607 | ValidationCtx: cli.FlagValidationCtx{ 608 | Required: true, 609 | MinArgs: 1, 610 | MaxArgs: 1, 611 | }, 612 | }, 613 | cli.Flag{ 614 | Name: "resT", 615 | LongName: "reservation-times", 616 | Description: "This flag is required. Specifies the priority time list for the reservation in hh:mm format", 617 | ValidationCtx: cli.FlagValidationCtx{ 618 | Required: true, 619 | MinArgs: 1, 620 | MaxArgs: cli.InfiniteArgs, 621 | }, 622 | }, 623 | cli.Flag{ 624 | Name: "reqD", 625 | LongName: "request-date", 626 | Description: "This flag is required. Specifies the date to send request in yyyy:mm:dd:hh:mm format", 627 | ValidationCtx: cli.FlagValidationCtx{ 628 | Required: true, 629 | MinArgs: 1, 630 | MaxArgs: 1, 631 | }, 632 | }, 633 | cli.Flag{ 634 | Name: "ps", 635 | LongName: "party-size", 636 | Description: "This flag is required. Specifies the size of party", 637 | ValidationCtx: cli.FlagValidationCtx{ 638 | Required: true, 639 | MinArgs: 1, 640 | MaxArgs: 1, 641 | }, 642 | }, 643 | 644 | }, 645 | Handler: c.handleRats, 646 | } 647 | 648 | // 'rais' command 649 | raisCommand := cli.Command{ 650 | Name: "rais", 651 | Description: "Reserve At Interval Scheduler", 652 | Flags: []cli.Flag{ 653 | cli.Flag{ 654 | Name: "e", 655 | LongName: "email", 656 | Description: "This flag is optional if already logged in using Login command. Specifies login email", 657 | ValidationCtx: cli.FlagValidationCtx{ 658 | Required: false, 659 | MinArgs: 1, 660 | MaxArgs: 1, 661 | }, 662 | }, 663 | cli.Flag{ 664 | Name: "p", 665 | LongName: "password", 666 | Description: "This flag is optional if already logged in using Login command. Specifies login password", 667 | ValidationCtx: cli.FlagValidationCtx{ 668 | Required: false, 669 | MinArgs: 1, 670 | MaxArgs: 1, 671 | }, 672 | }, 673 | cli.Flag{ 674 | Name: "v", 675 | LongName: "venue-id", 676 | Description: "This flag is required. Specifies the venueu id(use search to find by name)", 677 | ValidationCtx: cli.FlagValidationCtx{ 678 | Required: true, 679 | MinArgs: 1, 680 | MaxArgs: 1, 681 | }, 682 | }, 683 | cli.Flag{ 684 | Name: "t", 685 | LongName: "table", 686 | Description: "This flag is optional. Used to set the type of table in order of preference. The available types are dining, patio, bar, lounge, indoor, and outdoor", 687 | ValidationCtx: cli.FlagValidationCtx{ 688 | Required: false, 689 | MinArgs: 1, 690 | MaxArgs: cli.InfiniteArgs, 691 | }, 692 | }, 693 | cli.Flag{ 694 | Name: "resD", 695 | LongName: "reservation-day", 696 | Description: "This flag is required. Specifies the day for the reservation in yyyy:mm:dd format", 697 | ValidationCtx: cli.FlagValidationCtx{ 698 | Required: true, 699 | MinArgs: 1, 700 | MaxArgs: 1, 701 | }, 702 | }, 703 | cli.Flag{ 704 | Name: "resT", 705 | LongName: "reservation-times", 706 | Description: "This flag is required. Specifies the priority time list for the reservation in hh:mm format", 707 | ValidationCtx: cli.FlagValidationCtx{ 708 | Required: true, 709 | MinArgs: 1, 710 | MaxArgs: cli.InfiniteArgs, 711 | }, 712 | }, 713 | cli.Flag{ 714 | Name: "i", 715 | LongName: "interval", 716 | Description: "This flag is required. Specifies the interval to send request on in hh:mm format", 717 | ValidationCtx: cli.FlagValidationCtx{ 718 | Required: true, 719 | MinArgs: 1, 720 | MaxArgs: 1, 721 | }, 722 | }, 723 | cli.Flag{ 724 | Name: "ps", 725 | LongName: "party-size", 726 | Description: "This flag is required. Specifies the size of party", 727 | ValidationCtx: cli.FlagValidationCtx{ 728 | Required: true, 729 | MinArgs: 1, 730 | MaxArgs: 1, 731 | }, 732 | }, 733 | 734 | }, 735 | Handler: c.handleRais, 736 | } 737 | 738 | 739 | // 'list' command 740 | listCommand := cli.Command{ 741 | Name: "list", 742 | Description: "List all operations", 743 | Flags: []cli.Flag{}, 744 | Handler: c.handleList, 745 | } 746 | 747 | // 'login' command 748 | loginCommand := cli.Command{ 749 | Name: "login", 750 | Description: "Set login defaults", 751 | Flags: []cli.Flag{ 752 | cli.Flag{ 753 | Name: "e", 754 | LongName: "email", 755 | Description: "This flag is required. Provides login email", 756 | ValidationCtx: cli.FlagValidationCtx{ 757 | Required: true, 758 | MaxArgs: 1, 759 | MinArgs: 1, 760 | }, 761 | }, 762 | cli.Flag{ 763 | Name: "p", 764 | LongName: "password", 765 | Description: "This flag is required. Provides login password", 766 | ValidationCtx: cli.FlagValidationCtx{ 767 | Required: true, 768 | MaxArgs: 1, 769 | MinArgs: 1, 770 | }, 771 | }, 772 | }, 773 | Handler: c.handleLogin, 774 | } 775 | 776 | // 'logout' command 777 | logoutCommand := cli.Command{ 778 | Name: "logout", 779 | Description: "Clear default login credentials", 780 | Flags: []cli.Flag{}, 781 | Handler: c.handleLogout, 782 | } 783 | 784 | // 'cancel' command 785 | cancelCommand := cli.Command{ 786 | Name: "cancel", 787 | Description: "Cancel operations given ids", 788 | Flags: []cli.Flag{ 789 | cli.Flag{ 790 | Name: "i", 791 | LongName: "id", 792 | Description: "This flag is required. It takes one to unmeasured number inputs, the ids of operations", 793 | ValidationCtx: cli.FlagValidationCtx{ 794 | Required: true, 795 | MinArgs: 1, 796 | MaxArgs: cli.InfiniteArgs, 797 | }, 798 | }, 799 | }, 800 | Handler: c.handleCancel, 801 | } 802 | 803 | // 'clean' command 804 | cleanCommand := cli.Command{ 805 | Name: "clean", 806 | Description: "Clean operations given ids", 807 | Flags: []cli.Flag{ 808 | cli.Flag{ 809 | Name: "i", 810 | LongName: "id", 811 | Description: "This flag is required. It takes one to unmeasured number inputs, the ids of operations", 812 | ValidationCtx: cli.FlagValidationCtx{ 813 | Required: true, 814 | MinArgs: 1, 815 | MaxArgs: cli.InfiniteArgs, 816 | }, 817 | }, 818 | }, 819 | Handler: c.handleClean, 820 | } 821 | 822 | // 'quit' command 823 | quitCommand := cli.Command{ 824 | Name: "quit", 825 | Description: "Exits the CLI", 826 | Flags: []cli.Flag{}, 827 | Handler: c.handleQuit, 828 | } 829 | 830 | // 'exit' command, same handler as 'quit' command 831 | exitCommand := cli.Command{ 832 | Name: "exit", 833 | Description: "Exits the CLI", 834 | Flags: []cli.Flag{}, 835 | Handler: c.handleQuit, 836 | } 837 | 838 | // 'help' command 839 | helpCommand := cli.Command{ 840 | Name: "help", 841 | Description: "Displays helpful info about commands", 842 | Flags: []cli.Flag{}, 843 | Handler: c.handleHelp, 844 | } 845 | 846 | // we init the parseCtx with the above info 847 | c.parseCtx = cli.ParseCtx{ 848 | OpenDelim: "[", 849 | CloseDelim: "]", 850 | Commands: []cli.Command{ 851 | searchCommand, 852 | cancelCommand, 853 | cleanCommand, 854 | listCommand, 855 | loginCommand, 856 | logoutCommand, 857 | ratsCommand, 858 | raisCommand, 859 | quitCommand, 860 | exitCommand, 861 | helpCommand, 862 | }, 863 | } 864 | } 865 | 866 | /* 867 | Name: Run 868 | Type: External Func 869 | Purpose: This function inits the 870 | parse ctx and enters into an infinite 871 | loop, starting the REPL 872 | */ 873 | func (c *ResolvedCLI) Run() (error) { 874 | // init the parse ctx w/the above handler 875 | c.initParseCtx() 876 | scanner := bufio.NewScanner(c.In) 877 | // print welcome msg 878 | fmt.Fprintln(c.Out, "Welcome to the Resolved CLI! For Help type 'help'") 879 | for { 880 | // print prompt 881 | fmt.Fprint(c.Out, "resolved(0.1.0)>> ") 882 | scanner.Scan() 883 | if err := scanner.Err(); err != nil { 884 | fmt.Fprintln(c.Err, err); 885 | } 886 | // parse input 887 | result, err := c.parseCtx.Parse(scanner.Text()) 888 | if err != nil { 889 | fmt.Fprint(c.Err, "ERROR: ") 890 | fmt.Fprintln(c.Err, err) 891 | } else { 892 | fmt.Fprintln(c.Out, result) 893 | } 894 | } 895 | return nil 896 | } 897 | 898 | 899 | -------------------------------------------------------------------------------- /runnable/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Author: Bruce Jagid 3 | Created On: Aug 12, 2023 4 | 5 | ********************************************************************** 6 | 7 | General Purpose: 8 | 9 | The runnable pkg provides a common namespace and interface 10 | to systems that wrap around the core app. Currently, the only 11 | working program made with the core app is in the runnable/cli 12 | directory, although the intent is to expand this to include 13 | some servers. 14 | 15 | We hope this abstraction and separation of concerns will achieve 16 | a few important things. First, since we strive to write all of 17 | the code without use of external pkgs, we have to implement a 18 | lot of our own front-end libraries, like our own cli parser or 19 | http server. In this case, it is much better to have separate 20 | pkgs where those utilities live and then one common place 21 | where one can 'attach' there front-end to the core app, which 22 | is what we want the runnable sub-pkg space to be. We also want a 23 | common way of running complete systems, such that we may be able 24 | to select between available systems at runtime through flags. 25 | 26 | ********************************************************************** 27 | */ 28 | package runnable 29 | -------------------------------------------------------------------------------- /runnable/runnable.go: -------------------------------------------------------------------------------- 1 | /* 2 | Author: Bruce Jagid 3 | Created On: Aug 12, 2023 4 | */ 5 | package runnable 6 | 7 | /* 8 | Name: Runnable 9 | Type: External Runnable Interface 10 | Purpose: Define the minimal expected 11 | behavior of a completed system, with 12 | front-end and back-end behavior 13 | */ 14 | type Runnable interface { 15 | Run() (error) 16 | } 17 | --------------------------------------------------------------------------------