From 0dcf5df6d0197635ee3a6c837c21022e630f657f Mon Sep 17 00:00:00 2001 From: Tor Andersson Date: Wed, 14 Dec 2022 17:19:10 +0100 Subject: Add Webhook notifications. --- schema.sql | 8 +++ server.js | 157 ++++++++++++++++++++++++++++++++++++++++++++++++------ views/profile.pug | 11 ++++ views/webhook.pug | 46 ++++++++++++++++ 4 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 views/webhook.pug diff --git a/schema.sql b/schema.sql index d4a6864..e2ca8a4 100644 --- a/schema.sql +++ b/schema.sql @@ -54,6 +54,13 @@ create table if not exists last_notified ( primary key (game_id, user_id) ) without rowid; +create table if not exists webhooks ( + user_id integer primary key, + url text, + prefix text, + error text +); + drop view if exists user_view; create view user_view as select @@ -427,6 +434,7 @@ create trigger trigger_delete_on_users after delete on users begin delete from logins where user_id = old.user_id; delete from tokens where user_id = old.user_id; + delete from webhooks where user_id = old.user_id; delete from user_last_seen where user_id = old.user_id; delete from last_notified where user_id = old.user_id; delete from read_threads where user_id = old.user_id; diff --git a/server.js b/server.js index 1ca151b..4e6a487 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,7 @@ const fs = require('fs') const crypto = require('crypto') const http = require('http') +const https = require('https') // for webhook requests const { WebSocketServer } = require('ws') const express = require('express') const url = require('url') @@ -267,6 +268,12 @@ const SQL_UPDATE_USER_PASSWORD = SQL("UPDATE users SET password=?, salt=? WHERE const SQL_UPDATE_USER_LAST_SEEN = SQL("INSERT OR REPLACE INTO user_last_seen (user_id,atime) VALUES (?,julianday())") const SQL_UPDATE_USER_IS_BANNED = SQL("update users set is_banned=? where name=?") +const SQL_SELECT_WEBHOOK = SQL("SELECT * FROM webhooks WHERE user_id=?") +const SQL_SELECT_WEBHOOK_SEND = SQL("SELECT url, prefix FROM webhooks WHERE user_id=? AND error is null") +const SQL_UPDATE_WEBHOOK = SQL("INSERT OR REPLACE INTO webhooks (user_id, url, prefix, error) VALUES (?,?,?,null)") +const SQL_UPDATE_WEBHOOK_ERROR = SQL("UPDATE webhooks SET error=? WHERE user_id=?") +const SQL_DELETE_WEBHOOK = SQL("DELETE FROM webhooks WHERE user_id=?") + const SQL_FIND_TOKEN = SQL("SELECT token FROM tokens WHERE user_id=? AND julianday() < time + 0.004").pluck() const SQL_CREATE_TOKEN = SQL("INSERT OR REPLACE INTO tokens (user_id,token,time) VALUES (?, lower(hex(randomblob(16))), julianday()) RETURNING token").pluck() const SQL_VERIFY_TOKEN = SQL("SELECT EXISTS ( SELECT 1 FROM tokens WHERE user_id=? AND julianday() < time + 0.020 AND token=? )").pluck() @@ -552,6 +559,28 @@ app.get('/unsubscribe', must_be_logged_in, function (req, res) { res.redirect('/profile') }) +app.get('/webhook', must_be_logged_in, function (req, res) { + req.user.notify = SQL_SELECT_USER_NOTIFY.get(req.user.user_id) + let webhook = SQL_SELECT_WEBHOOK.get(req.user.user_id) + res.render('webhook.pug', { user: req.user, webhook: webhook }) +}) + +app.post("/delete-webhook", must_be_logged_in, function (req, res) { + SQL_DELETE_WEBHOOK.run(req.user.user_id) + res.redirect("/webhook") +}) + +app.post("/update-webhook", must_be_logged_in, function (req, res) { + let url = req.body.url + let prefix = req.body.prefix + SQL_UPDATE_WEBHOOK.run(req.user.user_id, url, prefix) + const webhook = SQL_SELECT_WEBHOOK_SEND.get(req.user.user_id) + if (webhook) + send_webhook(req.user.user_id, webhook, "Test message!") + res.setHeader("refresh", "3; url=/webhook") + res.send("Testing Webhook. Please wait...") +}) + app.get('/change-name', must_be_logged_in, function (req, res) { res.render('change_name.pug', { user: req.user }) }) @@ -1218,6 +1247,7 @@ function annotate_games(games, user_id, unread) { app.get('/profile', must_be_logged_in, function (req, res) { req.user.notify = SQL_SELECT_USER_NOTIFY.get(req.user.user_id) + req.user.webhook = SQL_SELECT_WEBHOOK.get(req.user.user_id) res.render('profile.pug', { user: req.user }) }) @@ -1668,6 +1698,92 @@ app.get('/replay-debug/:game_id', function (req, res) { return res.json({players, state, replay}) }) +/* + * WEBHOOK NOTIFICATIONS + */ + +const webhook_options = { + method: "POST", + timeout: 6000, + headers: { + "Content-Type": "application/json" + } +} + +function on_webhook_success(user_id) { + console.log("WEBHOOK SENT", user_id) +} + +function on_webhook_error(user_id, error) { + console.log("WEBHOOK FAIL", user_id, error) + SQL_UPDATE_WEBHOOK_ERROR.run(error, user_id) +} + +function send_webhook(user_id, webhook, message) { + try { + const data = JSON.stringify({ content: webhook.prefix + " " + message }) + const req = https.request(webhook.url, webhook_options, res => { + if (res.statusCode === 200 || res.statusCode === 204) + on_webhook_success(user_id) + else + on_webhook_error(user_id, res.statusCode + " " + http.STATUS_CODES[res.statusCode]) + }) + req.on("timeout", () => { + on_webhook_error(user_id, "Timeout") + req.abort() + }) + req.on("error", (err) => { + on_webhook_error(user_id, err.toString()) + }) + req.write(data) + req.end() + } catch (err) { + on_webhook_error(user_id, err.message) + } +} + +function webhook_game_link(game, user) { + if (user.role) + return `${SITE_URL}/${game.title_id}/play:${game.game_id}:${encodeURI(user.role)}` + return `${SITE_URL}/join/${game.game_id}` +} + +function webhook_ready_to_start(user, game_id) { + let webhook = SQL_SELECT_WEBHOOK_SEND.get(user.user_id) + if (webhook) { + let game = SQL_SELECT_GAME_VIEW.get(game_id) + let message = webhook_game_link(game, user) + " - Ready to start!" + send_webhook(user.user_id, webhook, message) + } +} + +function webhook_game_started(user, game_id) { + let webhook = SQL_SELECT_WEBHOOK_SEND.get(user.user_id) + if (webhook) { + let game = SQL_SELECT_GAME_VIEW.get(game_id) + let message = webhook_game_link(game, user) + " - Started!" + send_webhook(user.user_id, webhook, message) + } +} + +function webhook_game_over(user, game_id) { + let webhook = SQL_SELECT_WEBHOOK_SEND.get(user.user_id) + if (webhook) { + let game = SQL_SELECT_GAME_VIEW.get(game_id) + let message = webhook_game_link(game, user) + " - Finished!" + send_webhook(user.user_id, webhook, message) + } +} + +function webhook_your_turn(user, game_id) { + let webhook = SQL_SELECT_WEBHOOK_SEND.get(user.user_id) + if (webhook) { + let game = SQL_SELECT_GAME_VIEW.get(game_id) + let message = webhook_game_link(game, user) + " - Your turn!" + send_webhook(user.user_id, webhook, message) + } +} + /* * MAIL NOTIFICATIONS */ @@ -1799,34 +1915,44 @@ function mail_your_turn_notification_to_offline_users(game_id, old_active, activ let players = SQL_SELECT_PLAYERS.all(game_id) for (let p of players) { - if (p.notify) { - let p_was_active = (old_active === p.role || old_active === 'Both' || old_active === 'All') - let p_is_active = (active === p.role || active === 'Both' || active === 'All') - if (!p_was_active && p_is_active) { - if (is_online(game_id, p.user_id)) { + let p_was_active = (old_active === p.role || old_active === 'Both' || old_active === 'All') + let p_is_active = (active === p.role || active === 'Both' || active === 'All') + if (!p_was_active && p_is_active) { + if (is_online(game_id, p.user_id)) { + if (p.notify) reset_your_turn_notification(p, game_id) - } else { - mail_your_turn_notification(p, game_id, 15 * MINUTES) - } } else { - reset_your_turn_notification(p, game_id) + if (p.notify) + mail_your_turn_notification(p, game_id, 15 * MINUTES) + webhook_your_turn(p, game_id) } + } else { + if (p.notify) + reset_your_turn_notification(p, game_id) } } } function mail_game_started_notification_to_offline_users(game_id, owner_id) { let players = SQL_SELECT_PLAYERS.all(game_id) - for (let p of players) - if (p.notify && !is_online(game_id, p.user_id)) - mail_game_started_notification(p, game_id) + for (let p of players) { + if (!is_online(game_id, p.user_id)) { + if (p.notify) + mail_game_started_notification(p, game_id) + webhook_game_started(p, game_id) + } + } } function mail_game_over_notification_to_offline_users(game_id, result, victory) { let players = SQL_SELECT_PLAYERS.all(game_id) - for (let p of players) - if (p.notify && !is_online(game_id, p.user_id)) - mail_game_over_notification(p, game_id, result, victory) + for (let p of players) { + if (!is_online(game_id, p.user_id)) { + if (p.notify) + mail_game_over_notification(p, game_id, result, victory) + webhook_game_over(p, game_id) + } + } } function notify_your_turn_reminder() { @@ -1843,6 +1969,7 @@ function notify_ready_to_start_reminder() { if (owner) { if (owner.notify) mail_ready_to_start_notification(owner, game.game_id, 25 * HOURS) + webhook_ready_to_start(owner, game.game_id) } } } diff --git a/views/profile.pug b/views/profile.pug index 7ed8eff..99c8f16 100644 --- a/views/profile.pug +++ b/views/profile.pug @@ -28,6 +28,17 @@ html br | Delete account + if !user.webhook + p Configure webhook + else if user.webhook.error + dl + dt Configure webhook + dd.error ERROR: #{user.webhook.error} + else + dl + dt Configure webhook + dd= new URL(user.webhook.url).hostname + p Chat log p diff --git a/views/webhook.pug b/views/webhook.pug new file mode 100644 index 0000000..bf7673f --- /dev/null +++ b/views/webhook.pug @@ -0,0 +1,46 @@ +//- vim:ts=4:sw=4: +doctype html +html(lang="en") + head + include head + title Webhook + body + include header + article + + h1 Webhook + + - var url = webhook && webhook.url || "" + - var prefix = webhook && webhook.prefix || "" + + form(action="/update-webhook" method="post") + if webhook && webhook.error + p.error ERROR: #{webhook.error} + p Webhook URL: + br + input#url(type="text" name="url" size=120 placeholder="https://discord.com/api/webhooks/..." value=url required) + p Message prefix: + br + input#prefix(type="text" name="prefix" size=40 placeholder="<@123456789>" value=prefix) + + if webhook + button(type="submit") Update + else + button(type="submit") Create + + if webhook + form(action="/delete-webhook" method="post") + button(type="submit") Delete + + h2 Discord Notifications + + p You can send notifications to a given channel on a Discord server. + + ol + li Create your own server or use an existing server where you have administrator privileges. + li Get the webhook URL for the Discord channel where you want notifications to be sent. Enter it into the Webhook URL field. + li Find your Discord User ID. Enter it into the Message prefix field as "<@UserID>" + + h2 Custom Notifications + + p You can integrate with any server that accepts inbound webhooks by setting the webhook URL to the appropriate endpoint. The webhook payload is a JSON object with a "content" property containing the notification message. -- cgit v1.2.3