Discordia tutorial part 2: Firebase database

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

More Information

11 Likes

anyone know where to get the helper.lua file?

i’m stuck at the very first step, how do you “enable databases” what things do i have to select? when it asks me what i want to use google firebase for these are the only options i get:
image
frankly you should have explained this.