Welcome to bluejay, a voice assistant that isn't always listening, that doesn't compile huge data troves of your speech and history, and that doesn't have any idea who you are. The trade-off is that you need to have some familiarity with coding to customize it to your needs - including creating developer accounts with any APIs you intend to use. But, at least in theory, bluejay is infinitely expandable - think of this as an engine that can power your use cases... with a ton of built-in functionality already.
Why did I build this? I wanted a voice assistant to perform various platform-specific searches, to read me the news, to control my smart home devices, etc. There are, of course, existing solutions to this... but I just don't trust the big players. I imagine they're using the audio recordings and transcripts for their own purposes, whether that's market research or personalizing ads or building new AI products. I wanted a system that provides more control over the flow of information and allows me to interact with APIs without creating a unified record of my preferences and history. But more than that, I built this because it seemed like fun.
A simple nodeJS application running locally is used for serving front-end files and proxying requests to external APIs.
The front-end is a vanilla Javascript application (using some ES6 syntax). The core engine lives in a single script, with an additional script containing the library of recognized phrases and executable actions.
Since there are no user accounts, all settings are stored locally in the browser's LocalStorage cache. This includes credentials to external APIs - nothing is stored in the code.
The native WebAudio API powers a simple pitch detection, which only stores frequency data - not words.
Another native API, SpeechRecognition kicks in once a whistle is detected, and converts speech into text. Note that Chrome technically sends this audio to Google servers for (presumably anonymous) processing.
This leverages the speech synthesis functionality of the device to transform response text into spoken words.
- Download and install the latest version of nodeJS.
- Download the bluejay .zip, open it, and move the files to the folder of your choice.
- Navigate to this location in Terminal, then to the folder
localhost
.npm start
- Alternately, create a new project on Google Firebase.
- This may incur minimal costs (pennies). If you want to use Firebase to call external APIs, such as to fetch information or control smart devices, Google requires a pay-as-you-go plan.
- Download and install the latest version of Firebase.
- Download the bluejay .zip, open it, and move the files to the folder of your choice.
- Navigate to this location in Terminal, then to the folder
firebase
.firebase init
- Follow the prompts to set up your Firebase project.
firebase deploy
- When the project is deployed, the logs will indicate the url of your Firebase project.
- Download and install the latest version of Google Chrome. Note: Safari and Firefox do not support SpeechRecognition API.
- If you have deployed using Firebase, simply navigate to the url of your project. Otherwise, read on...
- Option 1: easy, then annoying
- In
index.js
, ingetEnvironment
, setssl
tofalse
.- Open http://localhost:3000 in your browser. Note: since this is not https, the webpage will constantly ask you to grant audio permissions.
- Option 2: annoying, then easy
- On chrome://flags/#unsafely-treat-insecure-origin-as-secure, add
localhost
in the text field and set the select to Enabled.- On chrome://flags/#allow-insecure-localhost, switch to Enabled.
- Create a security certificate for localhost. (optional, to remove the red warning) You can do that with this command, from https://letsencrypt.org/docs/certificates-for-localhost/
openssl req -x509 -out localhost.crt -keyout localhost.key \ -newkey rsa:2048 -nodes -sha256 \ -subj '/CN=localhost' -extensions EXT -config <( \ printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
Then use your computer's Keychain Access or equivalent to Alsways Trust this certificate.- Open https://localhost:3000 in your browser. Note: on startup, Chrome may show you a reminder that you are treating localhost as https.
This is an overview of the user experience - how to interact with bluejay. Subsequent sections will explain how it works, how to set up developer accounts with APIs, and how to add your own functionality.
There are 4 input methods:
- Type a phrase into the text box.
- Click the circular button and speak a phrase.
- Whistle any interval (that is, two distinct notes), up or down, within an octave, to get bluejay's attention; then speak your phrase.
- After responding, most actions will cause bluejay to follow up and listen again.
The interface also provides a few settings, which you can access by clicking the "hamburger" icon.
- upload: Use this to import a simple JSON file of key/value pairs of configurations (such as api keys or favorite websites). Note that this information is stored in LocalStorage only.
- on: Uncheck this to turn off whistle detection. (While unchecked, input method #3 is unavailable.)
- listen (sec): How long should bluejay listen for words before processing them?
- volume: How loud should the speech synthesis response be?
- voice: Which voice (provided by the device) should the speech synthesis use?
Each response should include the following components:
- visual
- phrase: the exact user-input phrase detected
- icon: an emoji representing the action that the phrase matched to
- action: the name of the action that the phrase matched to; all actions are in the form of
infinitive verb
+details
, such as "get
+the time
"- timestamp: the hh:mm:ss time the response was generated
- responseHTML: the output generated by the action function, including:
- an
<h2>
with the primary output (often wrapped in an<a>
)- additional unstructured information
- additional structured information, such as a
<ul>
,<table>
,<svg>
,<img>
, or<iframe>
- auditory
- message: the spoken text that is fed to the SpeechSynthesis API
- contextual
- (Note: These items can add information to the
CONTEXT_LIBRARY
or change the format of the response.)followup
: set this tofalse
to prevent bluejay from automatically triggering SpeechRecognition againauto
: makes the action bar blueerror
: makes the action bar red, saves this action as incomplete and follows upnumber
: the main number of the response, if applicableword
: the main word or short phrase of the response, if applicabletime
: the main date/timestamp of the response, if applicableurl
: the main url of the response, if applicablevideo
: the id of the<iframe>
or<video>
of the response, if applicablelanguage
: the alternate language for the spoken response, if applicableresults
: a list of additional responses, if applicable, such as for a search action
- Clicking the large logo button on load creates a
new AudioContext()
which connects to the device microphone.- A
setInterval
runs a function every X ms which analyzes the latest microphone data. It attempts to understand the complexity, frequency, and energy (volume) of input sound.- If certain criteria are met across these categories (simple sine wave, frequency in the whistling range, sufficient loudness) then the frequency is converted to a pitch and stored. Only the last 10 iterations are saved.
- If an interval (that is, a difference between two pitches) of at least a minor second and at most a perfect fourth is detected, speech recognition is triggered.
- It also plays a chirp sound in an
<audio>
element. (Note: this is actually an edited recording of bluejays!)
- As indicated above, this can be triggered by whistling, clicking the button, or a followup command from a previous action.
- The
speechRecognition
API listens for X seconds or until it detects silence.- The audio is technically processed through a Google API, but this is all built-in to Chrome.
- The resultant object contains multiple possible text matches; only the first is used.
- A phrase is entered, either through speech recognition or by being manually typed in the text box.
- If the phrase exactly matches one of the elements in
ERROR_LIBRARY["stop-phrases"]
, the system will set the action tostop
. This will flush out any previously incomplete action or flow, as well as stop any playing video or speech synthesis. The system will not follow up.- If the last 6 characters of the phrase are
cancel
(case-insensitive), the system will abort this entirely and produce no output. The system will not follow up.- If a
flow
was set (that is, a multi-step action), then the entire phrase will be fed into that action. (Note that only astop
command will exit a flow prematurely.)
- Otherwise, the full phrase (trimmed and cleaned up a bit) is matched against every key in the
PHRASE_LIBRARY
. If there are no matches, the last word (broken at spaces) is moved to aremainder
string, and the preceding phrase is matched against thePHRASE_LIBRARY
. This continues until a match is found.- If a match is found, the
remainder
is fed into the action function indicated by thePHRASE_LIBRARY
.- If no match was found, but there was a previously incomplete action, the entire phrase is fed into that action function (similar to a flow above).
- If no match was found and there was no previously incomplete action, the system selects an error from
ERROR_LIBRARY["noaction-responses"]
and follows up to try again.
- If there was an action, the remainder (or
null
) will be fed into the function of that name in theACTION_LIBRARY
.- Generally, these actions will either transform the remainder, use it to search for information on an API, or send it to an API and return the result.
- The phrase, action, remainder, and response object are all fed into
createHistory
.- This function updates the
CONTEXT_LIBRARY
with the latest phrase, action, and remainder, as well as the response icon, message, html, and any additional parameters.- A history block is created (see Response Structure above).
- The SpeechSynthesis API will speak the response message (see Response Structure above).
- Unless there was an explicit command not to, the system will then follow up for another phrase.
PHRASE_LIBRARY
: the object populated bylibrary.js
containing all key-value pairs of spoken phrase to action name.ACTION_LIBRARY
: the object populated bylibrary.js
containing all the actions that can be invoked by the user.FUNCTION_LIBRARY
: the list of helper functions used throughout the front-end, such asgetAverage
andsortRandom
; all of the form handlers, such aschangeWhistleOn
andchangeRecognitionDuration
; all of theinitialize
functions for the other libraries, such asinitializeAudio
andinitializeRecognition
; all of the functions that communicate externally, such asproxyRequest
andsendPost
; and all of the functions described in this flow, such asmatchPhrase
andcreateHistory
. Basically, this is all functions except the ones users invoke in theACTION_LIBRARY
.ELEMENT_LIBRARY
: an object to more easily access DOM elements, such as the settings inputs.AUDIO_LIBRARY
: the object containing all data and functions powering the whistle detection.SOUND_LIBRARY
: the object containing all information powering the chirp sound.RECOGNITION_LIBRARY
: the object containing all data and functions powering the SpeechRecognition.VOICE_LIBRARY
: the object containing all data and functions powering the SpeechSynthesis.ERROR_LIBRARY
: an object of arrays ofstop-phrases
,noaction-responses
, anderror-responses
.CONTEXT_LIBRARY
: an object to store temporary values pertaining to the last phrase, action, response, etc.; this also contains information about the current flow and any current alarms.CONFIGURATION_LIBRARY
: an object saved inlocalStorage
that containssettings
related to the whistling, speech recognition, and speech synthesis components, as well as any user-provided API credentials, favorite websites and RSS feeds, and location information.NUMBER_WORD_LIBRARY
: a key-value mapping of number words to digits, such as"one": 1
.LETTER_WORD_LIBRARY
: a key-value mapping of letter words to letters, such as"sea": "c"
.
You can easily add your own functions to library.js:
- The
key
represents part of the user's spokenphrase
, matched from the beginning.- The
value
represents the name of the function to run.
- The name of the function should be all lowercase, only alphabetical, an imperative command from bluejay's point of view (ex:
"get the time"
or"search google"
).- All action functions take two inputs:
- The
remainder
is the rest of the user's spokenphrase
, everything that did not match in the PHRASE_LIBRARY, through to the end of the string.- The
callback
is the function to run with the output; this is generally going to beFUNCTION_LIBRARY.createHistory
.- Wrap the contents of the function in a
try { } catch (error) { }
.
- The default error handler is:
callback({icon: icon, error: true, message: "I was unable to " + arguments.callee.name + ".", html: "<h2>Unknown error in <b>" + arguments.callee.name + "</b>:</h2>" + error})
- Identify the
icon
to display in the response.
- This should be in the format
\&\#x1f426;
where the characters betweenx
and;
represent the HTML code for an emoji character.- Format the
remainder
as necessary, such as.trim()
or using a.replace(/some regex/gi, "")
.- Perform the action logic, including accessing external APIs.
- Generally, use a "
error
→callback
andreturn
" structure. For example, check for a required configuration before attempting to use it, and callback with an error message if it's missing.- Use
FUNCTION_LIBRARY
functions wherever possible, such asproxyRequest to have the server make an API request, or
getDigits
to transform number words ("one") into numerals (1).- If the action requires multiple steps, set
CONTEXT_LIBRARY.flow
to the name of this function, and store all temporary information within a new object atCONTEXT_LIBRARY[name of this functon]
. Make sure todelete
this and unset theflow
at the end.- Format the output. This will usually be:
callback({icon: icon, message: message, html: responseHTML})
(See the Response Structure section for more options.)
Many of bluejay's actions require fetching information from external data sources.
Some actions can get information from a publicly accessible HTML page, or an XML or RSS feed:
- "find etymology" → https://www.etymonline.com/word/{search}
- "define idiom" → https://www.thefreedictionary.com/_/partner.aspx?Set=idioms&Word={search}
- "get a fact" → https://www.snapple.com/real-facts/
- "get the latest post" → RSS feeds
- "get a random post" → RSS feeds
- "get all posts" → RSS feeds
- "get the headlines" → RSS feeds
Other actions involve fetching information from an external API. Several of these APIs require no authentication, and will therefore work right out of the box:
- "get rhymes" → https://api.datamuse.com/words?rel_rhy={search}
- "get synonyms" → https://api.datamuse.com/words?rel_syn={search}
- "get antonyms" → https://api.datamuse.com/words?rel_ant={search}
- "get definition" → https://api.datamuse.com/words?md=d&sp={search}
- "get a poem" → https://poetrydb.org/random
- "get a joke" → https://icanhazdadjoke.com/slack
- "get a fortune" → http://yerkee.com/api/fortune
- "get a compliment" → https://complimentr.com/api
- "get an insult" → https://evilinsult.com/generate_insult.php?lang=en&type=json
- "play true or false" → https://opentdb.com/api.php?amount=10&type=boolean
- "get a wikipedia entry" → https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&utf8=1&srsearch={search} & https://en.wikipedia.org/w/api.php?action=parse&format=json&utf8=1&page={search}
- "get this day in history" → https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&utf8=1&srsearch={search} & https://en.wikipedia.org/w/api.php?action=parse&format=json&utf8=1&page={search}
- "get a book" → http://openlibrary.org/search.json?q={search}
- "play true or false" → https://opentdb.com/api.php?amount=10&type=boolean
- "play hangman" → http://random-word-api.herokuapp.com/word?number=100
- "play mad libs" → http://madlibz.herokuapp.com/api/random?minlength=5&maxlength=25
- "get the sunrise" → https://api.sunrise-sunset.org/json?lat={lat}&lng={long}&date={date}
- "get the sunset" → https://api.sunrise-sunset.org/json?lat={lat}&lng={long}&date={date}
- "get metro predictions" → https://api-v3.mbta.com/predictions?sort=stop_sequence&include=stop&filter[route]={id} & https://api-v3.mbta.com/alerts?filter[route]={id}
- (Note: These are Firebase Custom Functions I created, adapted from my own projects.)
- "convert" → https://us-central1-projects-3bd0e.cloudfunctions.net/unitconverter?quantity={quantity}&from={unit}&to={unit}
- "find factors" → https://us-central1-projects-3bd0e.cloudfunctions.net/factorfinder?number={search}
- "analyze chord" → https://us-central1-projects-3bd0e.cloudfunctions.net/chordanalyzer?notes={search}
- "shuffle word" → https://us-central1-projects-3bd0e.cloudfunctions.net/wordshuffler?word={search}
- "convert base" → https://us-central1-projects-3bd0e.cloudfunctions.net/baseconverter?numberString={quantity}oldBase={oldBase}&newBase={newBase}
- "encrypt message" → https://us-central1-projects-3bd0e.cloudfunctions.net/messageencrypter?action=encrypt&message={message}&keyword={keyword}
- "decrypt message" → https://us-central1-projects-3bd0e.cloudfunctions.net/messageencrypter?action=decrypt&message={message}&keyword={keyword}
Other APIs will require you to create a developer account and add your credentials into LocalStorage, either using the "change configuration" action or by uploading a JSON file through the interface.
- actions:
- "trigger ifttt" → https://maker.ifttt.com/trigger/bluejay_{command}/with/key/{ifttt key} with optional body
{"value1": number}
- "turn on ifttt device" → https://maker.ifttt.com/trigger/bluejay_{command}_on/with/key/{ifttt key}
- "turn off ifttt device" → https://maker.ifttt.com/trigger/bluejay_{command}_off/with/key/{ifttt key}
- "toggle ifttt device" → https://maker.ifttt.com/trigger/bluejay_{command}_toggle/with/key/{ifttt key}
- setup:
- Create a free account: https://ifttt.com
- Connect your other accounts, such as Philips Hue or SmartLife.
- Navigate to https://ifttt.com/create to create a new applet.
- For "THIS" use the Webhooks service. Select Receive a web request.. Name the event: bluejay_{command}. Your command should use underscores instead of spaces, and it should end with "on", "off", or "toggle". Example: bluejay_kitchen_lights_on
- For "THAT" select your external service, then select the action from the available list, and select any settings within the menus.
- Save this and finish.
- After creating your commands, go to the Webhooks settings at https://ifttt.com/maker_webhooks/settings. Your key is the last part of the URL shown: https://maker.ifttt.com/use/{your key}.
- Save your key in bluejay as
ifttt key
.
- actions:
- "get the weather" → https://api.openweathermap.org/data/2.5/forecast?appid={key}&q={search},us&mode=json&units=imperial
- "get todays weather" → https://api.openweathermap.org/data/2.5/forecast?appid={key}&q={search},us&mode=json&units=imperial
- "get tomorrows weather" → https://api.openweathermap.org/data/2.5/forecast?appid={key}&q={search},us&mode=json&units=imperial
- "get a days weather" → https://api.openweathermap.org/data/2.5/forecast?appid={key}&q={search},us&mode=json&units=imperial
- setup:
- Create a free account: https://home.openweathermap.org/users/sign_up
- Save your key as
open weather api
.
- actions:
- "get nutrition facts" → https://api.spoonacular.com/recipes/guessNutrition?apiKey={key}&title={search}
- "get nutrition answer" → https://api.spoonacular.com/recipes/quickAnswer?apiKey={key}&title={search}
- setup:
- Create a free account: https://spoonacular.com/food-api/console
- Save your key as
spoonacular api
.
- actions:
- "get stock price" → https://www.alphavantage.co/query?apikey={key}&function=SYMBOL_SEARCH&keywords={search} & https://www.alphavantage.co/query?apikey={key}&function=TIME_SERIES_DAILY&symbol={symbol}
- setup:
- Create a free account: https://www.alphavantage.co/support/#api-key
- Save your key as
alphavantage api
.
- actions:
- "get a movie" → https://www.omdbapi.com/?apikey={key}&s={search} & https://www.omdbapi.com/?apikey={key}&plot=full&i={id}
- setup:
- Create a free account: http://www.omdbapi.com/apikey.aspx
- Save your key as
omdb api
.
- actions:
- "find lyrics" → https://www.stands4.com/services/v2/lyrics.php?format=json&uid={stands4 id}&tokenid={stands4 api key}&term={title search}&artist={artist search} → https://www.lyrics.com/db-print.php?id={song id}
- setup:
- Create a free account: https://www.lyrics.com/api.php
- Save your user id as
stands4 id
.- Save your token as
stands4 api key
.
- actions:
- "get the time" → https://maps.googleapis.com/maps/api/geocode/json?key={key}&address={search} & https://maps.googleapis.com/maps/api/timezone?key={key}&location={lat,long}×tamp={seconds}
- "get the sunrise" → https://maps.googleapis.com/maps/api/geocode/json?key={key}&address={search}
- "get the sunset" → https://maps.googleapis.com/maps/api/geocode/json?key={key}&address={search}
- "google geolocate" → https://maps.googleapis.com/maps/api/geocode/json?key={key}&address={search}
- "search google directions" → https://maps.googleapis.com/maps/api/directions/json?key={key}&origin={search}&destination={search}
- "search google places" → https://maps.googleapis.com/maps/api/place/findplacefromtext/json?key={key}&inputtype=textquery&fields=place_id&input={search} & https://maps.googleapis.com/maps/api/place/details/json?key={key}&inputtype=textquery&fields=name,photo,url,website,formatted_phone_number,formatted_address,geometry,opening_hours&place_id={id}
- "search youtube" → https://www.googleapis.com/youtube/v3/search?key={key}&part=snippet&type=video&videoEmbeddable=true&maxResults=10&order=viewCount&q={search}
- "google translate" → https://translation.googleapis.com/language/translate/v2?key={key}&q={search}&target={languageCode}
- "search google timezone" → https://maps.googleapis.com/maps/api/geocode/json?key={key}&address={search} & https://maps.googleapis.com/maps/api/timezone/json?key={key}&location={lat,long}×tamp={seconds}
- setup:
- Use your existing Google account or create one here: https://www.google.com/
- Create a new project on Google API Console: https://console.developers.google.com/projectcreate
- Enable the APIs you want to use: https://console.developers.google.com/apis/library
- Go to https://console.developers.google.com/apis/credentials and create a new key. Set
Application restrictions
toNone
andAPI restrictions
toDon't restrict key
.- Save the API Key as
google api key
. Note that this is free for 1 year, but costs pennies after that.
- actions:
- "search google" → {custom search url}&key={key}&q={search}
- setup:
- Use your existing Google account or create one here: https://www.google.com/
- Create a new Custom Search Engine here: https://cse.google.com/cse/all
- Add a new search engine to search any random website, such as https://www.example.com.
- Edit the search engine to remove this website from "Sites to search".
- Instead, set "Search the entire web" to
ON
.- Save your "Public URL" as
google custom search
.- You will also need the
google api key
from above.
If you're anything like me, you have a lot of information within Google applications, such as Docs, Sheets, Gmail, Calendar, and Contacts. For that, you can create use Google Apps Script to publish a script to the web to serve as an API endpoint into your account.
- actions:
- "edit wish list" → {url}&action=listWish&item={name}&cost={cost}&type={type}b>
- "get balance" → {url}&action=getBalance&account={name}
- "log purchase" → {url}&action=logPurchase&category={name}&description={description}&amount={cost}
- "fetch calendar" → {url}&action=fetchEvents&startDate={date}&endDate={date}
- "find event" → {url}&action=findEvent&name={name}
- "add event" → {url}&action=addEvent&title={name}&startDate={startDate}&startTime={startTime}&endDate={endDate}&endTime={endTime}&location={location}
- "get a list" → {url}&action=getList&list={name}
- "add an item to a list" → {url}&action=addTask&list={name}&task={description}
- "get contacts" → {url}&action=getContacts&name={name}
- "get birthday" → {url}&action=getContacts&name={name}
- "get phone number" → {url}&action=getContacts&name={name}
- "get email" → {url}&action=getContacts&name={name}
- "get address" → {url}&action=getContacts&name={name}
- "draft email" → {url}&action=draftEmail&recipient={name}&subject={subject}&body={body}
- "log gratitude" → {url}&action=logGratitude&text={text}
- setup:
- Use your existing Google account or create one here: https://www.google.com/
- Go to https://script.google.com/home/my to create a new script.
- See below for an example script that would fetch your Google Tasks. Note that this involves generating a secret key to send along with each request, ensuring only you can access this.
- Save the script and select
Publish > Deploy as a web app...
- Set
Execute the app as:
to yourself andWho has access to the app:
toAnyone, even anonymous
. ForProject version:
, selectNew
and add a commit message, then clickUpdate
.- Approve whatever new access the application needs. Your browser may warn you that this is unsafe, because it's an unknown developer... but that "unknown developer" is you. So proceed anyway.
- For each script, save the public url as
google apps script
orgoogle apps script {#}
. (I use one script url, with?action=
as a query parameter.)function doGet(event) { if (event && event.parameter && event.parameter.key == {secret key}) { if (event.action == "getList") { return ContentService.createTextOutput(JSON.stringify(getList(event))) } return ContentService.createTextOutput(JSON.stringify({ success: false, message: "Unknown action." })) } return ContentService.createTextOutput(JSON.stringify({ success: false, message: "Unauthorized." })) } function getList(event) { var allLists = Tasks.Tasklists.list() var listName = (event.parameter.list || "Default List").toLowerCase().trim() for (var i in allLists.items) { if (allLists.items[i].title.toLowerCase().trim() == listName) { var list = allLists.items[i] break } } if (list) { var listContents = Tasks.Tasks.list(list.id) || {items: []} return {success: true, listName: list.title, list: listContents.items} } return {success: false, message: "Unknown list."} }
Finally, some APIs require Oauth, because you could be writing information to a user's account (yours, presumably). Broadly speaking, Oauth involves sending users to another platform's website where they authenticate and are then redirected back to your site. Of course, since bluejay lives atlocalhost
, that doesn't work. Here's the bizarre workaround I engineered:
- Parse the user response or look in the
CONFIGURATION_LIBRARY
for the platform'skey
,secret
, andredirect
.- Create a popup window of the platform's auth screen; the
state
query param will include the bluejay url and platform APIkey
and/orsecret
, encrypted as required for later.- The user (also you) clicks through and completes the auth flow in the popup window.
- The external platform then redirects the popup to the
redirect
parameter, which must match the one on file in your developer settings. Believe it or not, I'm using a Google Apps Script for this. The platform will send the authorizationcode
as a query parameter.- I have a Google Apps Script that captures and logs this request. It splits the
state
parameter into the bluejay url and the platform APIsecret
, and uses the authorizationcode
parameter sent from the platform.- The Google Apps Script page returns a tiny HTML page with a <script> that automatically redirects to bluejay's
/authorization
endpoint, with anembeddedPost
parameter.- This page sends uses proxyRequest to send the
embeddedPost
to the bluejay server, which sends an API request to the external platform with the authorizationcode
received earlier.- The platform finally responds with the actual
access_token
,refresh_token
, andexpiration
.- The bluejay server sends these results back to the /authorization page, which immediately stores them in localStorage.
- The main bluejay window has actually been checking localStorage this whole time, and now that there is a value set for this data, the page finally saves these values to
CONFIGURATION_LIBRARY
.- It also announces that it was a success, and automatically closes the popup window from before.
- actions:
- "authorize platform" → https://api.wink.com/oauth2/authorize
- "get wink devices" → https://api.wink.com/users/me/wink_devices?client_id={key}&client_secret={secret}
- "set wink device" → https://api.wink.com/{type}s/{id}/desired_state?client_id={key}&client_secret={secret}
- setup:
- Create a Google Apps Script to handle Oauth redirects (see below).
- Go to https://developer.wink.com/clients and create an account.
- Click
NEW
to start a new application.- Save the Google Apps Script url to
REDIRECT URIS
on the developer page.- Save Client ID as
wink key
, Client Secret aswink secret
, and this Google Apps Script url aswink redirect
.- Say "authorize wink" and follow the Oauth flow as a user.
function doGet(event) { return authorize(event) } function doPost(event) { return authorize(event) } function authorize(event) { try { var code = event.parameter.code || "" var state = (event.parameter.state || "").split(";;;") || [] var bluejayUrl = state[0].replace("http://","https://") var authorization = state[1] var postUrl = "https://api.wink.com/oauth2/token" var data = { "method": "post", "url": postUrl, "Content-Type": "application/json", "body": { "grant_type": "authorization_code", "code": code, "client_secret": authorization } } var link = bluejayUrl + "authorization?embeddedPost=" + encodeURIComponent(JSON.stringify(data)) var response = HtmlService.createHtmlOutput("Redirecting to<br>" + "<a href='" + link + "'>" + link + "</a>" + "<script>window. { " + "window.location = '" + link + "'" + " }</script>") response.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) return response } catch (error) { return ContentService.createTextOutput(error) } }
- actions:
- "authorize platform" → https://api.sonos.com/login/v3/oauth
- "get sonos devices" → https://api.ws.sonos.com/control/api/v1/households & https://api.ws.sonos.com/control/api/v1/households/{id}/groups
- "set sonos devices" → https://api.ws.sonos.com/control/api/v1/groups/{id}/playback/play & https://api.ws.sonos.com/control/api/v1/groups/{id}/playback/pause & https://api.ws.sonos.com/control/api/v1/groups/{id}/playback/skipToNextTrack & https://api.ws.sonos.com/control/api/v1/groups/{id}/playback/skipToPreviousTrack & https://api.ws.sonos.com/control/api/v1/groups/{id}/groupVolume/relative & https://api.ws.sonos.com/control/api/v1/groups/{id}/groupVolume & https://api.ws.sonos.com/control/api/v1/players/{id}/playerVolume/relative & https://api.ws.sonos.com/control/api/v1/players/{id}/playerVolume
- "get now playing on sonos" → https://api.ws.sonos.com/control/api/v1/groups/{id}/playbackMetadata
- "get favorites on sonos" → https://api.ws.sonos.com/control/api/v1/households/{id}/favorites
- "play favorite on sonos" → https://api.ws.sonos.com/control/api/v1/groups/{id}/favorites
- setup:
- Create a Google Apps Script to handle Oauth redirects (see below).
- Go to https://integration.sonos.com/users/sign_up and create an account.
- On https://integration.sonos.com/integrations, click
New control integration
.- Save the Google Apps Script url to
Redirect URIs
on the Credentials page.- Save Key as
sonos key
, Secret assonos secret
, and this Google Apps Script url assonos redirect
.- Say "authorize sonos" and follow the Oauth flow as a user.
function doGet(event) { return authorize(event) } function doPost(event) { return authorize(event) } function authorize(event) { try { var code = event.parameter.code || "" var state = (event.parameter.state || "").split(";;;") || [] var bluejayUrl = state[0].replace("http://","https://") var authorization = state[1] var redirectURI = encodeURIComponent({this Google Apps Script's public url}) var postUrl = "https://api.sonos.com/login/v3/oauth/access?grant_type=authorization_code&code=" + code + "&redirect_uri=" + redirectURI var data = { "method": "post", "url": postUrl, "Authorization": "Basic " + authorization, "Content-Type": "application/x-www-form-urlencoded;charset=utf-8" } var link = bluejayUrl + "authorization?embeddedPost=" + encodeURIComponent(JSON.stringify(data)) var response = HtmlService.createHtmlOutput("Redirecting to<br>" + "<a href='" + link + "'>" + link + "</a>" + "<script>window. { " + "window.location = '" + link + "'" + " }</script>") response.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) return response } catch (error) { return ContentService.createTextOutput(error) } }
- actions:
- "authorize platform" → https://www.reddit.com/api/v1/authorize
- "get a reddit post" → https://oauth.reddit.com/r/{subreddit}
- setup:
- Create a Google Apps Script to handle Oauth redirects (see below).
- Go to https://reddit.com/ and create an account.
- Go to https://www.reddit.com/prefs/apps/ and click
"create app...
.- Save the Google Apps Script url to
redirect uri
on the developer page.- Save the random string below the application name as
reddit key
, secret asreddit secret
, and this Google Apps Script url asreddit redirect
.- Say "authorize reddit" and follow the Oauth flow as a user.
function doGet(event) { return authorize(event) } function doPost(event) { return authorize(event) } function authorize(event) { try { var code = event.parameter.code || "" var state = (event.parameter.state || "").split(";;;") || [] var bluejayUrl = state[0].replace("http://","https://") var authorization = state[1] var redirectURI = encodeURIComponent({this Google Apps Script's public url}) var postUrl = "https://www.reddit.com/api/v1/access_token?grant_type=authorization_code&code=" + code + "&redirect_uri=" + redirectURI var data = { "method": "post", "url": postUrl, "Authorization": "Basic " + authorization, "Content-Type": "application/x-www-form-urlencoded;charset=utf-8" } var link = bluejayUrl + "authorization?embeddedPost=" + encodeURIComponent(JSON.stringify(data)) var response = HtmlService.createHtmlOutput("Redirecting to<br>" + "<a href='" + link + "'>" + link + "</a>" + "<script>window. { " + "window.location = '" + link + "'" + " }</script>") response.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) return response } catch (error) { return ContentService.createTextOutput(error) } }