21Bruce / resolved-bot

Resolved is a resy bot that assists in obtaining difficult or high-end reservations. Although it only works with resy, there is an opentable api in the tree.
BSD 3-Clause "New" or "Revised" License
41 stars 10 forks source link

[FEATURE]: OpenTable API #7

Closed 21Bruce closed 1 year ago

21Bruce commented 1 year ago

Is there an existing issue for this feature?

Description of the problem

If a restaurant is only available for reservation on opentable, the app does not work

Planned Solution

Create an Open Table implementation of the API interface

Alternatives

I've thought of creating a new object / application for Open Table specifically since it uses 2-step login, but I believe generalizing the bot's API pkg to represent 2-step login is a good idea.

Solution Specifics

I've already begun dissecting the internals of Open Table's API using postman, still unsure of how to manage logins, but I believe I might be able to introduce semantics for token refreshing so that multiple logins may not be required. This can improve the response times on the resy API as well

21Bruce commented 1 year ago

Quick update:

So I've figured out that sending the following HTTP Request message will email the user for confirmation, i've marked where to put the email with the ###EMAIL### token:

Verb: POST
Address: https://www.opentable.com/dapi/fe/gql?optype=mutation&opname=SendVerificationCodeEmail

Headers:
Origin: https://www.opentable.com
Referer: https://www.opentable.com/authenticate/start?isPopup=true&rp=https%3A%2F%2Fwww.opentable.com%2F&srs=1&isFromBookingFlow=false
x-csrf-token: 2e96d1b2-400c-4b31-9a35-1b978d67501a
x-query-timeout: 2000

Body:
{
    "operationName":"SendVerificationCodeEmail",
    "variables":{
        "verifyEmail":false,
        "email":"###EMAIL###",
        "loginType":"popup-redirect",
        "requestedAction":"https://www.opentable.com/",
        "path":"https://www.opentable.com/"
        },
        "extensions":{
            "persistedQuery":{
                "sha256Hash":"b2d378ab902bfbc650cc50fe6e20415c9ce3803c9a82fe830f7834ef31c403fe"
                }
        }
}

It appears the header x-csrf-token: 2e96d1b2-400c-4b31-9a35-1b978d67501a and the value "sha256Hash":"b2d378ab902bfbc650cc50fe6e20415c9ce3803c9a82fe830f7834ef31c403fe" must be used to authenticate the request, and I've tested that these particular values can be re-used for different emails. x-query-timeout can be set to arbitrary values and can still work, but must be set to some value for the request to work.

The server will respond with a message with the following body:

{
    "data": {
        "sendVerificationCodeEmail": {
            "correlationId": "e4cbea19-cdcd-4502-b965-78b1d14a33cd",
            "tooManyRequests": false,
            "__typename": "SendVerificationCodeResult"
        }
    },
    "loading": false,
    "networkStatus": 7
}

The "correlationId": "e4cbea19-cdcd-4502-b965-78b1d14a33cd" field is a unique identifier that must be used in later requests to link the verification code with this login session, and it changes value on each send of the above request. The "tooManyRequests": false field indicates when the server will block the client due to excessive requesting. This block is short-term.

21Bruce commented 1 year ago

After the request in the above is sent, two further requests must be made. First, a request must be made of content type x-protobuffer that I'm still looking into, although I believe this request sends a long byte string to a google service in order to compute some sort of CAPTCHA token. I haven't found a way to send this in postman, but in firefox this packet can be sent with a few authentication values that I've recorded in order to obtain a new CAPTCHA token. For the next request, we need the CAPTCHA Token. After that is retrieved, we may send the following request, with the token ###CAPTCHA### where the previously obtained CAPTCHA token should go, the token ###COID### where the correlationId field value should go from the previous server response, the token ###EMAIL### where the user email should go, and the token ###CODE### where the confirmation code from the email should go.

Verb: POST
Address: https://www.opentable.com/dapi/fe/proxy/authentication-consumer-backend/authentication/start-passwordless-login
Headers:
Origin: https://www.opentable.com
Referer: https://www.opentable.com/authenticate/verify-medium?isPopup=true&rp=https%3A%2F%2Fwww.opentable.com%2F&srs=1&isFromBookingFlow=false
x-csrf-token: c6f3e69c-23af-4e96-8cf5-372b7d66a0e1

Body:
{
    "email":"###EMAIL###",
    "correlationId":"###COID###",
    "verificationCode":"###CODE###",
    "loginContextToken":"{\"loginType\":\"popup-redirect\",\"requestedAction\":\"https://www.opentable.com/\"}",
    "recaptchaToken":"###CAPTCHA###",
    "tld":"com",
    "databaseRegion":"na",
    "checkExistingEmailEnabled":true
}
21Bruce commented 1 year ago

Just looked into the above, and it appears one does not even need to login to make a reservation on open table, so while the above info is useful, we do not need it.

21Bruce commented 1 year ago

It appears the sha256Hash field values are analagous to API Keys, but they work depending on the requested function. So logging in has its own sha256Hash key, searching has its own key, and reserving has its own key

21Bruce commented 1 year ago

Working on the search function right now, and managed to get through to the opentable servers on go. Please note, there's some funky behavior with how the opentable servers handle the user-agent header. You must include it, but it doesn't actually have to have anything in it, so feel free to set it to the empty string

21Bruce commented 1 year ago

Quick Note: The following HTTP message can be used to find metadata in order to prepare a reservation. I use the token ###RID### to denote where the venueID should go, ###RESD### where the reservation day should go, ###REST### where the reservation time should go , ###PS### where the party size should go, and ###XTOK### where the x-csrf-token should go:

Verb: POST
Address: https://www.opentable.com/dapi/fe/gql?optype=query&opname=RestaurantsAvailability
Headers(on top of normal headers): 
x-csrf-token: ###XTOK###
Body:
{
    "operationName": "RestaurantsAvailability",
    "variables": {
        "onlyPop": false,
        "forwardDays": 0,
        "requireTimes": false,
        "requireTypes": [],
        "restaurantIds": [
            ###RID###
        ],
        "date": "###RESD###",
        "time": "###REST###",
        "partySize": ###PS###,
        "databaseRegion": "NA"
    },
    "extensions": {
        "persistedQuery": {
            "version": 1,
            "sha256Hash": "e6b87021ed6e865a7778aa39d35d09864c1be29c683c707602dd3de43c854d86"
        }
    }
}

This will trigger a response from the server with the following example body:

Body:
{
    "data": {
        "availability": [
            {
                "restaurantId": 1287013,
                "restaurantAvailabilityToken": "eyJ2IjoyLCJtIjowLCJwIjowLCJzIjowLCJuIjowfQ",
                "availabilityDays": [
                    {
                        "noTimesReasons": [],
                        "earlyCutoff": null,
                        "sameDayCutoff": null,
                        "dayOffset": 0,
                        "allowNextAvailable": true,
                        "topExperience": null,
                        "slots": [
                            {
                                "isAvailable": true,
                                "timeOffsetMinutes": -60,
                                "slotHash": "3120410745",
                                "pointsType": "Standard",
                                "pointsValue": 100,
                                "experienceIds": [],
                                "slotAvailabilityToken": "eyJ2IjoyLCJtIjowLCJwIjowLCJjIjo2LCJzIjowLCJuIjowfQ",
                                "attributes": [
                                    "highTop"
                                ],
                                "isMandatory": false,
                                "isMandatoryBySeating": [
                                    {
                                        "tableCategory": "highTop",
                                        "isMandatory": false,
                                        "__typename": "IsMandatoryBySeating"
                                    }
                                ],
                                "experiencesBySeating": [],
                                "redemptionTier": "GreatDeal",
                                "type": "Standard",
                                "__typename": "AvailableSlot"
                            },
                            {
                                "isAvailable": true,
                                "timeOffsetMinutes": -30,
                                "slotHash": "283703391",
                                "pointsType": "Standard",
                                "pointsValue": 100,
                                "experienceIds": [],
                                "slotAvailabilityToken": "eyJ2IjoyLCJtIjowLCJwIjowLCJjIjo2LCJzIjowLCJuIjowfQ",
                                "attributes": [
                                    "highTop"
                                ],
                                "isMandatory": false,
                                "isMandatoryBySeating": [
                                    {
                                        "tableCategory": "highTop",
                                        "isMandatory": false,
                                        "__typename": "IsMandatoryBySeating"
                                    }
                                ],
                                "experiencesBySeating": [],
                                "redemptionTier": "GreatDeal",
                                "type": "Standard",
                                "__typename": "AvailableSlot"
                            },
                            {
                                "isAvailable": true,
                                "timeOffsetMinutes": 0,
                                "slotHash": "1620779529",
                                "pointsType": "Standard",
                                "pointsValue": 100,
                                "experienceIds": [],
                                "slotAvailabilityToken": "eyJ2IjoyLCJtIjowLCJwIjowLCJjIjo2LCJzIjowLCJuIjowfQ",
                                "attributes": [
                                    "highTop"
                                ],
                                "isMandatory": false,
                                "isMandatoryBySeating": [
                                    {
                                        "tableCategory": "highTop",
                                        "isMandatory": false,
                                        "__typename": "IsMandatoryBySeating"
                                    }
                                ],
                                "experiencesBySeating": [],
                                "redemptionTier": "GreatDeal",
                                "type": "Standard",
                                "__typename": "AvailableSlot"
                            },
                            {
                                "isAvailable": true,
                                "timeOffsetMinutes": 15,
                                "slotHash": "335170686",
                                "pointsType": "Standard",
                                "pointsValue": 100,
                                "experienceIds": [],
                                "slotAvailabilityToken": "eyJ2IjoyLCJtIjowLCJwIjowLCJjIjo2LCJzIjowLCJuIjowfQ",
                                "attributes": [
                                    "highTop"
                                ],
                                "isMandatory": false,
                                "isMandatoryBySeating": [
                                    {
                                        "tableCategory": "highTop",
                                        "isMandatory": false,
                                        "__typename": "IsMandatoryBySeating"
                                    }
                                ],
                                "experiencesBySeating": [],
                                "redemptionTier": "GreatDeal",
                                "type": "Standard",
                                "__typename": "AvailableSlot"
                            },
                            {
                                "isAvailable": true,
                                "timeOffsetMinutes": 30,
                                "slotHash": "142556990",
                                "pointsType": "Standard",
                                "pointsValue": 100,
                                "experienceIds": [],
                                "slotAvailabilityToken": "eyJ2IjoyLCJtIjowLCJwIjowLCJjIjo2LCJzIjowLCJuIjowfQ",
                                "attributes": [
                                    "highTop"
                                ],
                                "isMandatory": false,
                                "isMandatoryBySeating": [
                                    {
                                        "tableCategory": "highTop",
                                        "isMandatory": false,
                                        "__typename": "IsMandatoryBySeating"
                                    }
                                ],
                                "experiencesBySeating": [],
                                "redemptionTier": "GreatDeal",
                                "type": "Standard",
                                "__typename": "AvailableSlot"
                            }
                        ],
                        "__typename": "AvailabilityDay"
                    }
                ],
                "__typename": "RestaurantAvailability"
            }
        ]
    },
    "loading": false,
    "networkStatus": 7
}

Each entry in the "availabilityDays" data array represents metadata for a time slot, with the "timeOffsetMinutes" field value representing the difference between the time for that slot and the requested time, the "isAvailable" field value representing whether the slot is free, and the "slotHash" and "slotAvailabilityToken" being used to identify the slot in the next request for finalizing the reservation

21Bruce commented 1 year ago

Closed with recent PR