A discord bot for teal.fm
discord
tealfm
music
1import { serve, type HttpBindings } from "@hono/node-server";
2import { env } from "@tealfmbot/common/constants";
3import { logger } from "@tealfmbot/common/logger";
4import { Hono } from "hono";
5import { deleteCookie, getSignedCookie } from "hono/cookie";
6import { html } from "hono/html";
7import { validator } from "hono/validator";
8import pinoHttpLogger from "pino-http";
9
10import { client } from "./client.js";
11import {
12 createSession,
13 getSessionAgent,
14 MAX_AGE,
15 validateIdentifier,
16 getSession,
17} from "./utils.js";
18
19type Variables = {
20 logger: typeof logger;
21};
22
23const app = new Hono<{ Bindings: HttpBindings; Variables: Variables }>();
24
25app.use(async (c, next) => {
26 await new Promise<void>((resolve) =>
27 pinoHttpLogger({
28 autoLogging: false,
29 })(c.env.incoming, c.env.outgoing, () => resolve()),
30 );
31
32 c.set("logger", c.env.incoming.log);
33
34 await next();
35});
36
37app.get("/dashboard", async (c) => {
38 const session = await getSession(c, env);
39 if (session) {
40 const agent = await getSessionAgent(c);
41 return c.html(html`
42 <h1>Dashboard</h1>
43 <p>DID: ${agent?.assertDid}</p>
44 <form method="post" action="/logout">
45 <button type="submit">Log out</button>
46 </form>
47 `);
48 }
49 return c.redirect("/login");
50});
51
52app.get("/health", (c) => {
53 return c.json({ message: "OK" }, 200);
54});
55
56app.get("/oauth-client-metadata.json", (c) => {
57 c.header("Cache-Control", `max-age=${MAX_AGE}, public`);
58 return c.json(client.clientMetadata);
59});
60
61app.get("/.well-known/jwks.json", (c) => {
62 c.header("Cache-Control", `max-age=${MAX_AGE}, public`);
63 return c.json(client.jwks);
64});
65
66app.get("/oauth/callback", async (c) => {
67 c.header("Cache-Control", "no-store");
68 const params = new URLSearchParams(c.req.url.split("?")[1]);
69
70 try {
71 const session = await getSession(c, env);
72 if (session) {
73 try {
74 const oauthSession = await client.restore(session);
75 if (oauthSession) oauthSession.signOut();
76 } catch (error) {
77 logger.warn({ error }, "oauth restore failed");
78 }
79 }
80 const oauth = await client.callback(params);
81 const cookie = await createSession(oauth.session.did);
82 c.header("Set-Cookie", cookie);
83 } catch (error) {
84 logger.error({ error }, "oauth callback failed");
85 }
86
87 return c.redirect("/dashboard");
88});
89
90app.get("/login", async (c) => {
91 const session = await getSession(c, env);
92 if (session) {
93 return c.redirect("/dashboard");
94 }
95
96 return c.html(
97 html`
98 <form action="/login" method="post">
99 <label for="identifier">Identifier</label>
100 <input id="identifier" name="identifier" type="text" placeholder="handle.bsky.social" required />
101 <button type="submit">Log in</button>
102 </form>
103 `,
104 );
105});
106
107app.post(
108 "/login",
109 validator("form", (value, c) => {
110 const identifier = value["identifier"];
111 if (typeof identifier !== "string" || !validateIdentifier(identifier)) {
112 return c.json({ message: "Invalid handle, did or PDS URL" }, 400);
113 }
114
115 return {
116 identifier,
117 };
118 }),
119 async (c) => {
120 c.header("Cache-Control", "no-store");
121 const { identifier } = c.req.valid("form");
122 const ac = new AbortController();
123 try {
124 const url = await client.authorize(identifier, {
125 signal: ac.signal,
126 });
127 return c.redirect(url.href.toString());
128 } catch (error) {
129 logger.error({ error }, "oauth authorize failed");
130 return c.json({ message: "oauth authorize failed" }, 500);
131 }
132 },
133);
134
135app.post("/logout", async (c) => {
136 c.header("Cache-Control", "no-store");
137 const session = await getSession(c, env);
138 if (session) {
139 try {
140 const oauthSession = await client.restore(session);
141 if (oauthSession) await oauthSession.signOut();
142 } catch (error) {
143 logger.warn({ error }, "Failed to revoke credentials");
144 }
145 }
146
147 deleteCookie(c, "__teal_fm_bot_session");
148
149 return c.redirect("/login");
150});
151
152const server = serve({
153 fetch: app.fetch,
154 port: 8002,
155});
156
157process.on("SIGINT", () => {
158 server.close();
159 process.exit(0);
160});
161process.on("SIGTERM", () => {
162 server.close((err) => {
163 if (err) {
164 console.error(err);
165 process.exit(1);
166 }
167 process.exit(0);
168 });
169});