How to add Firebase to your lua Discord bot
Intermediate
Prerequisites
- Previous Tutorial
Installation
lit install luvit/timer luvit/json luvit/secure-socket SinisterRectus/coro-http
https://github.com/Billiam/promise.lua
Code Editor
Something like Notepad won’t do so use something like Notepad++, Visual Studio Code, Atom, or something more fit.
Setup
We will base this off of the previous tutorial. We recommend you read it. We will implement Firebase. Create an account, then create a project. Enable databases. We will need the part before the firebaseio.com. In this case its test-63650, yours will be different.
Now you need to follow the instructions on how to get a access token unless you want to implement an uuid system. Keep your access token in a safe location. Make a firebase.lua file. We will make our own handler so the we can implement custom features better. Heres an example of what we will be able to do once we’re done.
local firebase = require('./firebase') -- Call the firebase file we are going to make
local db = firebase:new('test-63650', 'TOKEN') -- Create a new handler
local schema = db:newSchema('user', { -- Make a user schema
name = {type = 'string', identifier = true}, -- Make name a string and the identifier
surname = 'string' -- Make surname a string
})
local billy = schema:create({ -- Create a model named Billy
name = 'Billy', -- Set the name to Billy
surname = 'is from Florida' -- Set the surname to is from Florida
})
billy:next(function() -- Progress the promise
schema:get('Billy'):next(function(billy) -- Get Billy from the database
print(billy.Name) -- Returns Billy
end, function(err) -- Error catching
print('Error on getting',err) -- Output error
end)
end, function(err) -- Error catching
print('Error on creating',err) -- Output error
end)
So we start up by requiring the modules which we downloaded.
local json = require("json") -- Get json decoding and encoding
local http = require("coro-http") -- Send http requests
local helper = require('helper') -- Our helper library
local Promise = require('promise') -- The promise library
local timer = require('timer') -- To sync the event loop to the timer
local middleclass = helper.middleclass -- Get middleclass from helper
Now we have the modules we are going to need imported we need to create our classes. This would be our Firebase class, model class and schema class. We could do that buy adding these lines of code.
local firebase = middleclass('Firebase') -- Create the Firebase class
local Schema = middleclass('Firebase.Schema') -- Create the Schema class
local Model = middleclass('Firebase.Model') -- Create the Model class
The benefit of having a firebase class is multiple databases in one bot if you really wanted. Now we need to set our promise library to be in sync with our event loop. Usually modules like this assume a blocking interface which luvit doesn’t use. Thankfully it has a way to add this to it easily. Add these lines to have the promise library sync with the loop.
Promise.async = function(callback) -- Set the async call to a function
timer.setInterval(0, callback) -- Call the callback with a delay of an event
end
Now we need to add an initialization function or, a constructor. This can be done by creating a function in the class with the name of initialize. We need the root of the link and the auth token. This can be done with this code.
function firebase:initialize(root, auth) -- Initialize our class
self.root = root -- Link root to our class
self.auth = auth -- Link auth to our class
end
We have middleclass link root and auth to our firebase class. Now need need to be able to create a new schema so we create a function. we pass a function to our schema. What this function does is it makes a request to the database with our query. Now we should have this.
function firebase:newSchema(name, schema) -- Link newSchema to our class
return Schema:new(name,schema, function(node,method,callback,content) -- Create a new schema and pass the name,schema, and a function
local root = self.root -- Get the root
root = root:gsub("https://","") -- Remove https:// from the beginning
root = root:gsub("http://","") -- Or http
root = root:gsub("www.","") -- and www.
local url = "https://"..root..".firebaseio.com/"..node..".json?access_token="..self.auth -- Make our url with root, node(path) and auth
coroutine.wrap(function() -- Make a coroutine since http requests only get sent in coroutines and our promise library does asynchronously which http requests don't like
local headers,body = http.request(method,url,{{"Content-Type","application/json"}},content) -- Send a request with the method the schema needs, the url we made, the content type which should be json and our content(model)
if callback then -- If a callback is made then
body = json.decode(body) -- We decode the body
callback(headers.code ~= 200 and body,body) -- And send back error(if any) and the body
end
end)() -- Run the coroutine
end)
end
Now our firebase class is done. As you see in the code we create a new schema. Now we need to define the initialization of the class.
function Schema:initialize(name,schema, request) -- Initialize the schema class
self.name = name -- Link name to the schema
self.schema = schema -- Link schema to the schema
function self:request(node,method,callback,content) -- Link request to the schema
return request(node,method,callback,content) -- Call request (We need to do this since using a single dot removes the self variable which we need)
end
local id -- Define id
for i,v in pairs(schema) do -- Loop through the schema
if v.identifier then -- If an identifier is found
id = i -- Set id to it
if v.default then -- If it has a default value,
v.default = nil -- Set it to nill
end
break -- Then break out of the loop
end
end
if not id then -- No id exists
error('No identifier set') -- Error out since we need an id
end
self.id = id -- Link id to the schema
end
Now we can initialize the schema but, we can’t do anything with it. All it does is verifies if our schema has an identifier. We now need a create function to create our model. We can do this with another function.
function Schema:create(model) -- Link create to schema
local promise = Promise.new() -- Create a promise
for i,v in pairs(self.schema) do -- Loop through the schema
if not model[i] then -- Does this schema item exist in the model if not
if not self.schema[i].default then -- Does it have a default value if not
promise:reject('Value without default missing; ' .. i) -- Reject the promise
else
model[i] = self.schema[i].default -- Set the missing value to the default
end
else
if not (type(model[i]) == self.schema[i] or type(model[i]) == self.schema[i].type) then -- If the types of the model and the schema don't match up then
promise:reject('Model type is not the same as schema type;') -- Reject the promise
end
end
end
local prevModel = model -- Create a copy of the model
model = type(model) == "table" and json.encode(model) or model -- Encode the model into json
self:request(self.name .. '/' .. prevModel[self.id],"PUT",function(err,res) -- Call the request function with a path of the schema name / id, use method put have a callback, and send the encoded model
if err then -- If something went wrong
promise:reject(err) -- Reject the promise
else
local model = Model:new(self.schema, prevModel, self.name, prevModel[self.id], function (node,method,callback,content) -- Create a new model with the schema, model, id, and request function
return self:request(node,method,callback,content) -- Call the request function with the functions parameters
end)
promise:resolve(model) -- Resolve the promise and return the model
end
end,model)
return promise -- Return the promise
end
Now we can create models, but now how do we find them. We can find them by calling the request with the GET method. We pass the schema name and the id, callback and we get the model sent through the callback. Lets go and do that now.
function Schema:get(id) -- Link get to the schema
local promise = Promise.new() --Create a new promise
self:request(self.name .. '/' .. id,"GET",function(err,res) -- Call the request function with a path of the schema name / id, use method get have a callback
if err then -- If error then
promise:reject(err) -- Reject the promise
else
if not res then res = 'None' end -- If no data exists, set to none
promise:resolve(res) -- Resolve with res
end
end)
return promise -- Return with the promise
end
Now we only have 1 thing left to do, the model. All we really need is the creating and updating. We will do deleting once we need it. So for the creating, by looking at our schema we need to pass the schema, model, name, id, and request function. We can do that with this code.
function Model:initialize(schema, model, name, id, request) -- Initialize the model
self.schema = schema -- Link schema to model
self.model = model -- Link model to model
self.name = name -- Link name to model
self.id = id -- Link id to model
function self:request(node,method,callback,content) -- Link request to the schema
return request(node,method,callback,content) -- Call request (We need to do this since using a single dot removes the self variable which we need)
end
end
function Model:update(change)
local promise = Promise.new() -- Create a new promise
local content = type(change) == "table" and json.encode(change) or change -- Encode our change to the model
self:request(self.name .. '/' .. self.id,"PATCH",function(err,res) -- Call the request function with a path of the schema name / id, use method patch have a callback, and send our json encoded content
if err then -- If error then
promise:reject(err) -- Reject the promise
else
promise:resolve('Updated') -- Resolve the promise with updated
end
end,content)
return promise -- Return the promise
end
Now the only thing left is to return the firebase class.
return firebase
Here is the final code (without comments). Now we can use the test at the beginning.
local json = require("json")
local http = require("coro-http")
local helper = require('helper')
local Promise = require('promise')
local timer = require('timer')
local middleclass = helper.middleclass
local firebase = middleclass('Firebase')
local Schema = middleclass('Firebase.Schema')
local Model = middleclass('Firebase.Model')
Promise.async = function(callback)
timer.setInterval(0, callback)
end
function firebase:initialize(root, auth)
self.root = root
self.auth = auth
end
function firebase:newSchema(name, schema)
return Schema:new(name,schema, function(node,method,callback,content)
local root = self.root
root = root:gsub("https://","")
root = root:gsub("http://","")
root = root:gsub("www.","")
local url = "https://"..root..".firebaseio.com/"..node..".json?access_token="..self.auth
coroutine.wrap(function()
local headers,body = http.request(method,url,{{"Content-Type","application/json"}},content)
if callback then
body = json.decode(body)
callback(headers.code ~= 200 and body,body)
end
end)()
end)
end
function Schema:initialize(name,schema, request)
self.name = name
self.schema = schema
function self:request(node,method,callback,content)
return request(node,method,callback,content)
end
local id
for i,v in pairs(schema) do
if v.identifier then
id = i
if v.default then
v.default = nil
end
break
end
end
if not id then
error('No identifier set')
end
self.id = id
end
function Schema:create(model)
local promise = Promise.new()
for i,v in pairs(self.schema) do
if not model[i] then
if not self.schema[i].default then
promise:reject('Value without default missing; ' .. i)
else
model[i] = self.schema[i].default
end
else
if not (type(model[i]) == self.schema[i] or type(model[i]) == self.schema[i].type) then
promise:reject('Model type is not the same as schema type;')
end
end
end
local prevModel = model
model = type(model) == "table" and json.encode(model) or model
self:request(self.name .. '/' .. prevModel[self.id],"PUT",function(err,res)
if err then
promise:reject(err)
else
local model = Model:new(self.schema, prevModel, self.name, prevModel[self.id], function (node,method,callback,content)
return self:request(node,method,callback,content)
end)
promise:resolve(model)
end
end,model)
return promise
end
function Schema:get(id)
local promise = Promise.new()
self:request(self.name .. '/' .. id,"GET",function(err,res)
if err then
promise:reject(err)
else
if not res then res = 'None' end
promise:resolve(res)
end
end)
return promise
end
function Model:initialize(schema, model, name, id, request)
self.schema = schema
self.model = model
self.name = name
self.id = id
function self:request(node,method,callback,content)
return request(node,method,callback,content)
end
end
function Model:update(change)
local promise = Promise.new()
local content = type(change) == "table" and json.encode(change) or change
self:request(self.name .. '/' .. self.id,"PATCH",function(err,res)
if err then
promise:reject(err)
else
promise:resolve('Updated')
end
end,content)
return promise
end
return firebase
Run the test, if there are errors check the github repository or try to troubleshoot. The issue with this one is the token is going to expire. There is a solution but it requires some changes, we first need a script. Now add the google.lua to your requires.
local google = require('./google') -- Get google library
Now under the class creations add these links of code.
local refresh_coro = {} -- Create an empty table
local function reauthenticate(db) -- Define a function, reauthenticate
refresh_coro[db.root] = refresh_coro[db.root] or coroutine.wrap(function() -- If a coroutine doesn't exist for reauthentication then create one
local success, body = google.refresh(db.refresh, db.key) -- Refresh the token hopefully
if not success then -- If not
return print(string.format('%s | \27[1;33m[WARNING]\27[0m | %s', os.date('%F %T'), 'Request for Token Reauthentication Failed')) -- Output a warning for authentication failing
end
db.refresh = body.refresh_token -- Add new refresh token
db.auth = body.id_token -- Add new authentication token
expires = os.time() + body.expires_in -- Set expiration time
end)
refresh_coro[db.root]() -- Run our coroutine
end
Now we need to change our firebase initialization for these new things. Remove everything including the function and replace it with this.
function firebase:initialize(root, auth, authtab) -- Initialize the firebase
self.root = root -- Link root with class
coroutine.wrap(function() -- Create a coroutine just incase we run outside of one
local success, body = google.email(authtab[1] or authtab.email, authtab[2] or authtab.password, auth) -- Try to sign in with our service account and api token
if not success then -- If we can't,
return print(string.format('%s | \27[1;33m[WARNING]\27[0m | %s', os.date('%F %T'), 'Request for Token Authentication Failed')) -- Error out
end
self.email = body.email -- Link email to class
self.auth = body.idToken -- Link auth to class
self.refresh = body.refreshToken -- Link the refresh token to class
self.expires = os.time() + body.expiresIn -- Set expiration time
print(string.format('%s | \27[1;32m[INFO] \27[0m | %s', os.date('%F %T'), 'Firebase Authenticated: ' .. body.email)) -- Print out success
end)() -- Run coroutine
end
The last thing we need to change is creating a schema. We can do it simply with this.
function firebase:newSchema(name, schema) -- Link newSchema to the class
return Schema:new(name,schema, function(node,method,callback,content) -- Return a new schema with name, schema, and a function
local root = self.root -- Get root from self
root = root:gsub("https://","") -- Clear https://
root = root:gsub("http://","") -- Clear http://
root = root:gsub("www.","") -- CLear www.
if not self.key and not self.auth then return end -- If our key or authentication token doesn't exist return nil
if self.auth and self.expires <= os.time() - 30 then -- If we have our authentication and our token has expired,
reauthenticate(self) -- Reauthenticate with self
end
local url = "https://"..root..".firebaseio.com/"..node..".json?auth="..self.auth -- Construct our url
coroutine.wrap(function() -- Make a coroutine since http requests only get sent in coroutines and our promise library does asynchronously which http requests don't like
local headers,body = http.request(method,url,{{"Content-Type","application/json"}},content) -- Send a request with the method the schema needs, the url we made, the content type which should be json and our content(model)
if callback then -- If a callback is made then
body = json.decode(body) -- We decode the body
callback(headers.code ~= 200 and body,body) -- And send back error(if any) and the body
end
end)() -- Run the coroutine
end)
end
All of this allows us to reauthenticate when our token has expired preventing our database from going out.
Bot
Now that we have our firebase manager we can start with our bot. For this example we are going to make it so you get money every time you talk. You will be able to check your balance with a command. The hard part is over now. Create a new file in commands called Money.lua
. Now we need to edit our command handler to pass our settings. While on the topic we need to rearrange the order. Change it to be this.
if (msg.author.bot) then -- Is the message from a bot
return;
end
if (not msg.author) then -- Is it an automated message
return;
end
if (msg.author.id == client.user.id) then -- Did we send the message
return;
end
if (not string.startswith(msg.content, settings.Prefix)) then -- Check if it starts with our prefix
return;
end
local suc, err = pcall(function() -- Make sure the command doesn't nock out our bot
runCommand(msg);
end)
if not suc then -- Error logging, make sure its print or warn, if not it will end the program
print(err)
msg.channel:send('Something went wrong, please try again later')
end
We also need to add Cache to our settings. All you have to add the line Cache = {},
to the return dictionary. Since we have a cache, we need to use it. Between the starts with and checking if we sent the message we need to cache and give them money. This is easily done with these lines of code.
if not settings.Cache[msg.author.id] then -- If the user is not cached
settings.User:get(msg.author.id):next(function(usr) -- Get them from the database
if usr == 'None' then -- If they don't exist
settings.User:create({ -- Create the user using
id = msg.author.id -- their id
}):next(function(usr) -- Then we
settings.Cache[msg.author.id] = usr -- cache their user object
usr:getData():next(function(data) -- Request their data
settings.Cache[msg.author.id]:update({money = data.money + 1}) -- Update our money
end)
end)
end
settings.Cache[msg.author.id] = usr -- We cache them
usr:getData():next(function(data) -- We get their money
settings.Cache[msg.author.id]:update({money = data.money + 1}) -- We add one
end)
end)
else -- Else if they are cached
settings.Cache[msg.author.id]:getData():next(function(data) -- get their data
settings.Cache[msg.author.id]:update({money = data.money + 1}) -- and we add one dollar
end)
end
All this does is create, get, cache, and update their money. The only thing left is the command and adding the settings to the command handler. Over at commandObject.run add settings = settings
to the table. Now we need to open up the money.lua
file. In it we type
local helper = require('helper')
local RichEmbed = helper.embed
return {
name = 'money', -- Name of the command
alias = 'bal', -- Alias of the command
description = 'Check your balance', -- Description of the command
--type = '', Optional unless you have the optional part
runCommand = function(_, msg,_,rest) -- The run function
function handle(usr) -- Define a function handler
usr:getData():next(function(data) -- Get the userdata
local embed = RichEmbed:new() -- Create a new embed
embed:setTitle('Balance') -- Set the title
embed:setDescription('You have a balance of $' .. data.money+1) -- Set the balance (off by one since ,money is added after due to promises)
local co = coroutine.create(function(msg, embed) -- Create a coroutine since this is a promise
msg.channel:send(embed) -- Send the message
coroutine.yield() -- Yield the coroutine
end)
coroutine.resume(co,msg, embed) -- Launch the coroutine
end)
end
-- The same from main.lua except we add handle to the end
if not rest.settings.Cache[msg.author.id] then
rest.settings.User:get(msg.author.id):next(function(usr)
if usr == 'None' then
rest.settings.User:create({
id = msg.author.id
}):next(function(usr)
rest.settings.Cache[msg.author.id] = usr
usr:getData():next(function(data)
rest.settings.Cache[msg.author.id]:update({money = data.money + 1})
end)
handle(usr)
end)
end
rest.settings.Cache[msg.author.id] = usr
usr:getData():next(function(data)
rest.settings.Cache[msg.author.id]:update({money = data.money + 1})
end)
handle(usr)
end)
else
handle(rest.settings.Cache[msg.author.id])
end
end
}
Now everything should be done and ready. Make sure to use the github code as its more tested. I think I missed something like models so use the github.
Common Mistakes
- Using . instead of : to call functions, this messes with
self
- Capitalization
- You can only send an http call in a coroutine. This is a mistake since the timer module is asynchronous so in a timer you need to make a coroutine.
- Variables may not match up
- Classes