├── .gitignore ├── src └── workers │ ├── cron-reminder │ ├── package.json │ ├── wrangler.toml │ └── index.js │ ├── randengchat │ ├── package.json │ ├── wrangler.toml │ ├── public │ │ ├── index.js │ │ └── index.html │ └── server │ │ ├── index.js │ │ └── router.js │ └── cron-pair │ ├── webpack.config.js │ ├── wrangler.toml │ └── index.js ├── Makefile ├── package.json ├── .github └── workflows │ └── semgrep.yml ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | worker 3 | dist 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /src/workers/cron-reminder/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "BSD-3-Clause", 3 | "main": "./index.js" 4 | } 5 | -------------------------------------------------------------------------------- /src/workers/randengchat/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "BSD-3-Clause", 3 | "main": "./server/index.js" 4 | } 5 | -------------------------------------------------------------------------------- /src/workers/cron-pair/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: "./index.js", 3 | devtool: "inline-source-map", 4 | } 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | WORKERS := cron-pair cron-reminder randengchat 2 | 3 | .PHONY: all 4 | all: $(WORKERS) 5 | 6 | $(WORKERS): 7 | cd ./src/workers/$@ &&\ 8 | wrangler publish 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "edmonds-blossom": "^1.0.0", 4 | "moment": "^2.29.1", 5 | "raw-loader": "^4.0.1", 6 | "uuid": "^8.3.1" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/workers/cron-reminder/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "randengchat-cron-reminder" 2 | type = "webpack" 3 | account_id = "your account id" 4 | 5 | kv_namespaces = [ 6 | { binding = "DB", id = "your KV namespace id" }, 7 | ] 8 | 9 | [triggers] 10 | crons = ["0 13 * * 5"] 11 | -------------------------------------------------------------------------------- /src/workers/randengchat/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "randengchat" 2 | type = "webpack" 3 | account_id = "your account id" 4 | workers_dev = false 5 | route = "example.com/*" 6 | zone_id = "your zone id" 7 | 8 | kv_namespaces = [ 9 | { binding = "DB", id = "your KV namespace id" }, 10 | ] 11 | -------------------------------------------------------------------------------- /src/workers/cron-pair/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "randengchat-cron-pair" 2 | type = "webpack" 3 | account_id = "your account id" 4 | webpack_config = "webpack.config.js" 5 | 6 | kv_namespaces = [ 7 | { binding = "DB", id = "your KV namespace id" }, 8 | ] 9 | 10 | [triggers] 11 | crons = ["0 8 * * 2"] 12 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: {} 3 | workflow_dispatch: {} 4 | push: 5 | branches: 6 | - main 7 | - master 8 | schedule: 9 | - cron: '0 0 * * *' 10 | name: Semgrep config 11 | jobs: 12 | semgrep: 13 | name: semgrep/ci 14 | runs-on: ubuntu-latest 15 | env: 16 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 17 | SEMGREP_URL: https://cloudflare.semgrep.dev 18 | SEMGREP_APP_URL: https://cloudflare.semgrep.dev 19 | SEMGREP_VERSION_CHECK_URL: https://cloudflare.semgrep.dev/api/check-version 20 | container: 21 | image: semgrep/semgrep 22 | steps: 23 | - uses: actions/checkout@v4 24 | - run: semgrep ci 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Random Employee Chats 2 | 3 | Random Employee Chats was introduced by David Wragg and the idea is that two randomly selected engineers would meet in person in the office or schedule a 30-minute video call to discuss. You might learn a bit about their work and their team, but also get a new perspective on your own work. There's no fixed agenda, and nothing to prepare. 4 | 5 | During the pandemic, direct colleagues or from different teams wouldn’t meet in the office anymore. Having Random Engineering Chats is a good way to keep in touch or meet new people. 6 | 7 | Link to the [Blog post]. 8 | 9 | ## Workers 10 | 11 | - [UI]: Application UI 12 | - [Pairing]: Worker Cron for pairing 13 | - [Reminder]: Worker Cron for sending reminders 14 | 15 | ### Expected JS globals 16 | 17 | - `FEEDBACK_FORM`: Link to a form for people to leave feedback 18 | - `GCHAT_WEBHOOK`: Google chat webhook for notifications 19 | - `URL`: application URL 20 | 21 | ## License 22 | 23 | Licensed under the BSD-3-Clause license found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause. 24 | 25 | [UI]: src/workers/randengchat 26 | [Pairing]: src/workers/cron-pair 27 | [Reminder]: src/workers/cron-reminder 28 | [Blog post]: https://blog.cloudflare.com/random-employee-chats-cloudflare/ 29 | -------------------------------------------------------------------------------- /src/workers/cron-reminder/index.js: -------------------------------------------------------------------------------- 1 | function dedent(str) { 2 | str = str.replace(/^\n/, ""); 3 | let match = str.match(/^\s+/); 4 | return match ? str.replace(new RegExp("^" + match[0], "gm"), "") : str; 5 | } 6 | 7 | addEventListener("scheduled", (event) => { 8 | event.waitUntil(handleSchedule(event.scheduledTime)); 9 | }); 10 | 11 | async function handleSchedule(scheduledDate) { 12 | await notifyReminder(); 13 | } 14 | 15 | async function notifyReminder() { 16 | const text = dedent(` 17 | Hi , 18 | 19 | You participated to a Random Engineer Chat and it's time to sign up again! 20 | `); 21 | const msg = { 22 | text, 23 | cards: [ 24 | { 25 | sections: [ 26 | { 27 | widgets: [ 28 | { 29 | buttons: [ 30 | { 31 | textButton: { 32 | text: "register", 33 | onClick: { 34 | openLink: { 35 | url: globalThis.URL, 36 | }, 37 | }, 38 | }, 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | ], 45 | }, 46 | ], 47 | }; 48 | 49 | return await fetch(GCHAT_WEBHOOK, { 50 | method: "POST", 51 | headers: { "content-type": "application/json" }, 52 | body: JSON.stringify(msg), 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Cloudflare, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | 14 | -------------------------------------------------------------------------------- /src/workers/randengchat/public/index.js: -------------------------------------------------------------------------------- 1 | let userToken = ""; 2 | 3 | function onSignIn(googleUser) { 4 | userToken = googleUser.getAuthResponse().id_token; 5 | 6 | document.getElementById("register-btn").style = "display:block"; 7 | displayList(); 8 | } 9 | 10 | function register() { 11 | const data = { userToken }; 12 | 13 | fetch("/register", { 14 | method: "POST", 15 | headers: { 16 | "Content-Type": "application/json", 17 | }, 18 | body: JSON.stringify(data), 19 | }) 20 | .then((response) => response.text()) 21 | .then((data) => { 22 | console.log("Success:", data); 23 | 24 | displayList(); 25 | success("You successfully registered.
Since KV has cached the particpant list in the colo it can take up to 60s to show up."); 26 | }) 27 | .catch((error) => { 28 | console.error("Error:", error); 29 | }); 30 | } 31 | 32 | function success(msg) { 33 | const element = document.getElementById("ok-notif"); 34 | element.style = "display:block"; 35 | element.innerHTML = "Success: " + msg; 36 | } 37 | 38 | async function displayList() { 39 | const list = await ( 40 | await fetch("/list-registered?token=" + userToken) 41 | ).json(); 42 | const element = document.getElementById("list"); 43 | 44 | element.innerHTML = ""; 45 | 46 | if (Object.entries(list).length === 0) { 47 | element.innerHTML += "No one registered yet."; 48 | return; 49 | } 50 | 51 | for (let [key, value] of Object.entries(list)) { 52 | element.innerHTML += ` 53 |
54 | avatar 55 |

${value.name}

56 |

${value.email}

57 |
58 | `; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/workers/randengchat/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Random Engineer Chat 6 | 39 | 40 | 41 |
42 | 43 |

Random Engineer Chat

44 | 45 |

Participating

46 | 47 |
48 | 49 |

50 | 51 |

52 | 53 |

People registered

54 |
Please login first.
55 | 56 |

Useful infos

57 | 58 |

This site is entirely serverless.
Written using a Cloudflare Worker, KV and Cron Triggers.
See blog post.

59 | 60 | -------------------------------------------------------------------------------- /src/workers/randengchat/server/index.js: -------------------------------------------------------------------------------- 1 | import { Router } from "./router.js"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | import homepage from "raw-loader!../public/index.html"; 5 | import publicjs from "raw-loader!../public/index.js"; 6 | 7 | addEventListener("fetch", (event) => { 8 | event.respondWith(handleRequest(event.request)); 9 | }); 10 | 11 | async function getUser(token) { 12 | const res = await fetch( 13 | "https://oauth2.googleapis.com/tokeninfo?id_token=" + token 14 | ); 15 | const info = await res.json(); 16 | 17 | if ("error" in info) { 18 | throw new Error(info.error + ": " + info.error_description); 19 | } 20 | // check the hosted G Suite domain of the user 21 | if (info.hd !== "cloudflare.com") { 22 | throw new Error("user not allowed"); 23 | } 24 | 25 | return info; 26 | } 27 | 28 | async function handlePostRegister(request) { 29 | try { 30 | const { userToken } = await request.json(); 31 | const user = await getUser(userToken); 32 | if (!user) { 33 | return new Response("no user info; please login.", { status: 500 }); 34 | } 35 | 36 | const data = { 37 | name: user.name, 38 | picture: user.picture, 39 | email: user.email, 40 | id: user.sub, 41 | }; 42 | const key = "register:" + btoa(user.email); 43 | await DB.put(key, JSON.stringify(data)); 44 | 45 | return new Response(key); 46 | } catch (e) { 47 | return new Response(e.stack, { status: 500 }); 48 | } 49 | } 50 | 51 | async function handleListRegister(request) { 52 | const userToken = new URL(request.url).searchParams.get("token"); 53 | const user = await getUser(userToken); 54 | if (!user) { 55 | return new Response("no user info; please login.", { status: 500 }); 56 | } 57 | 58 | const list = await DB.list({ prefix: "register:" }); 59 | if (!list.list_complete) { 60 | throw new Error("unimplemented"); 61 | } 62 | 63 | let out = {}; 64 | for (let i = 0, len = list.keys.length; i < len; i++) { 65 | const key = list.keys[i].name; 66 | const data = await DB.get(key, "json"); 67 | out[key] = data; 68 | } 69 | 70 | return new Response(JSON.stringify(out)); 71 | } 72 | 73 | async function handleRequest(request) { 74 | try { 75 | const r = new Router(); 76 | r.get( 77 | "/public/index.js", 78 | () => 79 | new Response(publicjs, { 80 | headers: { 81 | "content-type": "application/javascript", 82 | }, 83 | }) 84 | ); 85 | 86 | r.get("/list-registered", handleListRegister); 87 | r.post("/register", handlePostRegister); 88 | 89 | r.get( 90 | "/", 91 | () => 92 | new Response(homepage, { 93 | headers: { 94 | "content-type": "text/html", 95 | }, 96 | }) 97 | ); 98 | 99 | r.get("/.*", () => new Response("404", { status: 404 })); 100 | 101 | return await r.route(request); 102 | } catch (e) { 103 | return new Response(e.stack, { status: 500 }); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/workers/randengchat/server/router.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper functions that when passed a request will return a 3 | * boolean indicating if the request uses that HTTP method, 4 | * header, host or referrer. 5 | */ 6 | const Method = (method) => (req) => 7 | req.method.toLowerCase() === method.toLowerCase(); 8 | const Connect = Method("connect"); 9 | const Delete = Method("delete"); 10 | const Get = Method("get"); 11 | const Head = Method("head"); 12 | const Options = Method("options"); 13 | const Patch = Method("patch"); 14 | const Post = Method("post"); 15 | const Put = Method("put"); 16 | const Trace = Method("trace"); 17 | 18 | const Header = (header, val) => (req) => req.headers.get(header) === val; 19 | const Host = (host) => Header("host", host.toLowerCase()); 20 | const Referrer = (host) => Header("referrer", host.toLowerCase()); 21 | 22 | const Path = (regExp) => (req) => { 23 | const url = new URL(req.url); 24 | const path = url.pathname; 25 | const match = path.match(regExp) || []; 26 | return match[0] === path; 27 | }; 28 | 29 | /** 30 | * The Router handles determines which handler is matched given the 31 | * conditions present for each request. 32 | */ 33 | export class Router { 34 | constructor() { 35 | this.routes = []; 36 | } 37 | 38 | handle(conditions, handler) { 39 | this.routes.push({ 40 | conditions, 41 | handler, 42 | }); 43 | return this; 44 | } 45 | 46 | connect(url, handler) { 47 | return this.handle([Connect, Path(url)], handler); 48 | } 49 | 50 | delete(url, handler) { 51 | return this.handle([Delete, Path(url)], handler); 52 | } 53 | 54 | get(url, handler) { 55 | return this.handle([Get, Path(url)], handler); 56 | } 57 | 58 | head(url, handler) { 59 | return this.handle([Head, Path(url)], handler); 60 | } 61 | 62 | options(url, handler) { 63 | return this.handle([Options, Path(url)], handler); 64 | } 65 | 66 | patch(url, handler) { 67 | return this.handle([Patch, Path(url)], handler); 68 | } 69 | 70 | post(url, handler) { 71 | return this.handle([Post, Path(url)], handler); 72 | } 73 | 74 | put(url, handler) { 75 | return this.handle([Put, Path(url)], handler); 76 | } 77 | 78 | trace(url, handler) { 79 | return this.handle([Trace, Path(url)], handler); 80 | } 81 | 82 | all(handler) { 83 | return this.handle([], handler); 84 | } 85 | 86 | route(req) { 87 | const route = this.resolve(req); 88 | 89 | if (route) { 90 | return route.handler(req); 91 | } 92 | 93 | return new Response("resource not found", { 94 | status: 404, 95 | statusText: "not found", 96 | headers: { 97 | "content-type": "text/plain", 98 | }, 99 | }); 100 | } 101 | 102 | /** 103 | * resolve returns the matching route for a request that returns 104 | * true for all conditions (if any). 105 | */ 106 | resolve(req) { 107 | return this.routes.find((r) => { 108 | if (!r.conditions || (Array.isArray(r) && !r.conditions.length)) { 109 | return true; 110 | } 111 | 112 | if (typeof r.conditions === "function") { 113 | return r.conditions(req); 114 | } 115 | 116 | return r.conditions.every((c) => c(req)); 117 | }); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/workers/cron-pair/index.js: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import blossom from "edmonds-blossom"; 3 | 4 | const TEST_MODE = false; 5 | 6 | addEventListener("scheduled", (event) => { 7 | event.waitUntil(handleSchedule(event.scheduledTime)); 8 | }); 9 | 10 | function dedent(str) { 11 | str = str.replace(/^\n/, ""); 12 | let match = str.match(/^\s+/); 13 | return match ? str.replace(new RegExp("^" + match[0], "gm"), "") : str; 14 | } 15 | 16 | function shuffle(a) { 17 | for (let i = a.length - 1; i > 0; i--) { 18 | const j = Math.floor(Math.random() * (i + 1)); 19 | [a[i], a[j]] = [a[j], a[i]]; 20 | } 21 | return a; 22 | } 23 | 24 | async function DBdelete(key) { 25 | if (TEST_MODE) { 26 | console.log("delete", key); 27 | return; 28 | } 29 | return DB.delete(key); 30 | } 31 | 32 | async function handleSchedule(scheduledDate) { 33 | const startOfWeek = moment().startOf("isoWeek").format("YYYY-MM-DD"); 34 | 35 | const list = await getlist(); 36 | let keys = shuffle(Object.keys(list)); 37 | 38 | // for odd number of participants either remove david or remove a random person. 39 | if (keys.length % 2 !== 0) { 40 | const davidKey = "register:" + btoa("dwragg@cloudflare.com"); 41 | let removedKey; 42 | 43 | if (keys.includes(davidKey)) { 44 | removedKey = davidKey; 45 | keys = keys.filter((x) => x !== davidKey); 46 | } else { 47 | removedKey = keys.pop(); 48 | } 49 | 50 | const user = list[removedKey]; 51 | 52 | await Promise.all([notifySorry(startOfWeek, user), DBdelete(removedKey)]); 53 | } 54 | 55 | async function createWeightedPairs() { 56 | const pairs = []; 57 | for (let i = 0; i < keys.length - 1; i++) { 58 | for (let j = i + 1; j < keys.length; j++) { 59 | const key = await getPairKey(list[keys[i]], list[keys[j]]); 60 | const count = await countTimesPaired(key); 61 | pairs.push([i, j, -1 * count]); 62 | } 63 | } 64 | return pairs; 65 | } 66 | 67 | const data = await createWeightedPairs(); 68 | const results = blossom(data, true); 69 | 70 | const alreadyMatched = new Set(); 71 | const matches = []; 72 | 73 | results.forEach((index, idx) => { 74 | if (index === -1) { 75 | const user = list[keys[idx]]; 76 | console.warn("could not match", user); 77 | return; 78 | } 79 | 80 | const left = list[keys[index]]; 81 | const right = list[keys[idx]]; 82 | 83 | if (!alreadyMatched.has(left) && !alreadyMatched.has(right)) { 84 | matches.push([left, right, keys[index], keys[idx]]); 85 | alreadyMatched.add(left); 86 | alreadyMatched.add(right); 87 | } 88 | }); 89 | 90 | for (let i = 0, len = matches.length; i < len; i++) { 91 | const [left, right, leftK, rightK] = matches[i]; 92 | 93 | await Promise.all([ 94 | notifyPaired(startOfWeek, left, right), 95 | recordPaired(left, right), 96 | DBdelete(leftK), 97 | DBdelete(rightK), 98 | ]); 99 | } 100 | } 101 | 102 | async function countTimesPaired(key) { 103 | const v = await DB.get(key, "json"); 104 | if (v !== null && v.count) { 105 | return v.count; 106 | } 107 | return 0; 108 | } 109 | 110 | function getPairKey(left, right) { 111 | if (left.email > right.email) { 112 | return `paired:${btoa(left.email)}/${btoa(right.email)}`; 113 | } else { 114 | return `paired:${btoa(right.email)}/${btoa(left.email)}`; 115 | } 116 | } 117 | 118 | async function recordPaired(left, right) { 119 | const key = getPairKey(left, right); 120 | const count = await countTimesPaired(key); 121 | const data = { 122 | emails: [left.email, right.email], 123 | count: count + 1, 124 | }; 125 | 126 | if (!TEST_MODE) { 127 | await DB.put(key, JSON.stringify(data)); 128 | } else { 129 | console.log("PUT", key, data); 130 | } 131 | } 132 | 133 | // TODO: share with server list endpoint 134 | async function getlist() { 135 | const list = await DB.list({ prefix: "register:" }); 136 | if (!list.list_complete) { 137 | throw new Error("unimplemented"); 138 | } 139 | 140 | let out = {}; 141 | for (let i = 0, len = list.keys.length; i < len; i++) { 142 | const key = list.keys[i].name; 143 | const data = await DB.get(key, "json"); 144 | out[key] = data; 145 | } 146 | 147 | return out; 148 | } 149 | 150 | async function notifyPaired(startOfWeek, left, right) { 151 | const details = dedent(` 152 | You signed up for a Random Engineer Chat for the week starting ${startOfWeek}, and the two of you have been paired! 153 | 154 | The idea is simple: 155 | 156 | Schedule a 30-minute hangout sometime this week at a mutually convenient time (make sure you have your "Working Hours" set in Google Calendar to make this easier). 157 | 158 | Then just talk about whatever comes up! By talking to someone on another team, you might learn a bit about their work and their team, but also get a new perspective on your own work. There's no fixed agenda, and nothing to prepare. 159 | 160 | Once you are done, please complete the very short feedback form at ${globalThis.FEEDBACK_FORM} to help determine whether this is worthwhile. 161 | `); 162 | 163 | const link = 164 | `https://www.google.com/calendar/render?action=TEMPLATE&text=Random+Engineer+Chat&add=${left.email}&add=${right.email}&details=` + 165 | details; 166 | 167 | const msg = { 168 | text: `Random Engineer Chat week starting ${startOfWeek} - you have paired !`, 169 | cards: [ 170 | { 171 | sections: [ 172 | { 173 | widgets: [ 174 | { 175 | buttons: [ 176 | { 177 | textButton: { 178 | text: "schedule", 179 | onClick: { 180 | openLink: { 181 | url: link, 182 | }, 183 | }, 184 | }, 185 | }, 186 | ], 187 | }, 188 | ], 189 | }, 190 | ], 191 | }, 192 | ], 193 | }; 194 | 195 | return await fetch(GCHAT_WEBHOOK, { 196 | method: "POST", 197 | headers: { "content-type": "application/json" }, 198 | body: JSON.stringify(msg), 199 | }); 200 | } 201 | 202 | async function notifySorry(startOfWeek, user) { 203 | const text = dedent(` 204 | Hi , 205 | 206 | You signed up for a Random Engineer Chat for the week starting ${startOfWeek}, unfortunately we weren't able to pair you with another person due to an odd number of participant. 207 | 208 | Please register on ${globalThis.URL} again for the next session. 209 | `); 210 | const msg = { text }; 211 | 212 | return await fetch(GCHAT_WEBHOOK, { 213 | method: "POST", 214 | headers: { "content-type": "application/json" }, 215 | body: JSON.stringify(msg), 216 | }); 217 | } 218 | --------------------------------------------------------------------------------