From 51dd5ec9dd35d87c2e93a8e5a64fa963caaa182a Mon Sep 17 00:00:00 2001 From: Dhravya Date: Sun, 23 Jun 2024 20:04:09 -0500 Subject: made and documented the telegram bot (HYPE) --- apps/cf-ai-backend/src/index.ts | 8 +++- apps/web/app/(auth)/signin/page.tsx | 14 +++++- apps/web/app/(dash)/home/page.tsx | 22 ++++++++- apps/web/app/actions/doers.ts | 31 ++++++++++++ apps/web/app/api/telegram/readme.md | 55 +++++++++++++++++++++ apps/web/app/api/telegram/route.ts | 89 ++++++++++++++++++++++++++++++++-- apps/web/server/encrypt.ts | 95 +++++++++++++++++++++++++++++++++++++ 7 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 apps/web/app/api/telegram/readme.md create mode 100644 apps/web/server/encrypt.ts diff --git a/apps/cf-ai-backend/src/index.ts b/apps/cf-ai-backend/src/index.ts index f2c69246..40b0f7b1 100644 --- a/apps/cf-ai-backend/src/index.ts +++ b/apps/cf-ai-backend/src/index.ts @@ -15,6 +15,7 @@ import { zValidator } from "@hono/zod-validator"; import chunkText from "./utils/chonker"; import { systemPrompt, template } from "./prompts/prompt1"; import { swaggerUI } from "@hono/swagger-ui"; +import { createOpenAI } from "@ai-sdk/openai"; const app = new Hono<{ Bindings: Env }>(); @@ -187,13 +188,18 @@ app.post( const { store, model } = await initQuery(c); + // we're creating another instance of the model here because we want to use a cheaper model for this. + const openai = createOpenAI({ + apiKey: c.env.OPENAI_API_KEY, + }); + let task: "add" | "chat" = "chat"; let thingToAdd: "page" | "image" | "text" | undefined = undefined; let addContent: string | undefined = undefined; // This is a "router". this finds out if the user wants to add a document, or chat with the AI to get a response. const routerQuery = await generateText({ - model, + model: openai.chat("gpt-3.5-turbo"), system: `You are Supermemory chatbot. You can either add a document to the supermemory database, or return a chat response. Based on this query, You must determine what to do. Basically if it feels like a "question", then you should intiate a chat. If it feels like a "command" or feels like something that could be forwarded to the AI, then you should add a document. You must also extract the "thing" to add and what type of thing it is.`, diff --git a/apps/web/app/(auth)/signin/page.tsx b/apps/web/app/(auth)/signin/page.tsx index d7bad8da..48074bf4 100644 --- a/apps/web/app/(auth)/signin/page.tsx +++ b/apps/web/app/(auth)/signin/page.tsx @@ -6,7 +6,17 @@ import { Google } from "@repo/ui/components/icons"; export const runtime = "edge"; -async function Signin() { +async function Signin({ + searchParams, +}: { + searchParams: Record; +}) { + const searchParamsAsString = Object.keys(searchParams) + .map((key) => { + return `${key}=${searchParams[key]}`; + }) + .join("&"); + return (
@@ -30,7 +40,7 @@ async function Signin() { action={async () => { "use server"; await signIn("google", { - redirectTo: "/home?firstTime=true", + redirectTo: `/home?firstTime=true&${searchParamsAsString}`, }); }} > diff --git a/apps/web/app/(dash)/home/page.tsx b/apps/web/app/(dash)/home/page.tsx index b6cfd223..a4235f1b 100644 --- a/apps/web/app/(dash)/home/page.tsx +++ b/apps/web/app/(dash)/home/page.tsx @@ -5,7 +5,8 @@ import QueryInput from "./queryinput"; import { homeSearchParamsCache } from "@/lib/searchParams"; import { getSpaces } from "@/app/actions/fetchers"; import { useRouter } from "next/navigation"; -import { createChatThread } from "@/app/actions/doers"; +import { createChatThread, linkTelegramToUser } from "@/app/actions/doers"; +import { toast } from "sonner"; function Page({ searchParams, @@ -14,11 +15,30 @@ function Page({ }) { // TODO: use this to show a welcome page/modal // const { firstTime } = homeSearchParamsCache.parse(searchParams); + + const [telegramUser, setTelegramUser] = useState( + searchParams.telegramUser as string, + ); + const { push } = useRouter(); const [spaces, setSpaces] = useState<{ id: number; name: string }[]>([]); useEffect(() => { + if (telegramUser) { + const linkTelegram = async () => { + const response = await linkTelegramToUser(telegramUser); + + if (response.success) { + toast.success("Your telegram has been linked successfully."); + } else { + toast.error("Failed to link telegram. Please try again."); + } + }; + + linkTelegram(); + } + getSpaces().then((res) => { if (res.success && res.data) { setSpaces(res.data); diff --git a/apps/web/app/actions/doers.ts b/apps/web/app/actions/doers.ts index 8833d5d2..56782440 100644 --- a/apps/web/app/actions/doers.ts +++ b/apps/web/app/actions/doers.ts @@ -8,6 +8,7 @@ import { contentToSpace, space, storedContent, + users, } from "../../server/db/schema"; import { ServerActionReturnType } from "./types"; import { auth } from "../../server/auth"; @@ -17,6 +18,7 @@ import { and, eq, inArray, sql } from "drizzle-orm"; import { LIMITS } from "@/lib/constants"; import { z } from "zod"; import { ChatHistory } from "@repo/shared-types"; +import { decipher } from "@/server/encrypt"; export const createSpace = async ( input: string | FormData, @@ -344,3 +346,32 @@ export const createChatObject = async ( data: true, }; }; + +export const linkTelegramToUser = async ( + telegramUser: string, +): ServerActionReturnType => { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + return { error: "Not authenticated", success: false }; + } + + const user = await db + .update(users) + .set({ telegramId: decipher(telegramUser) }) + .where(eq(users.id, data.user.id)) + .execute(); + + if (!user) { + return { + success: false, + data: false, + error: "Failed to link telegram to user", + }; + } + + return { + success: true, + data: true, + }; +}; diff --git a/apps/web/app/api/telegram/readme.md b/apps/web/app/api/telegram/readme.md new file mode 100644 index 00000000..79bd6f1c --- /dev/null +++ b/apps/web/app/api/telegram/readme.md @@ -0,0 +1,55 @@ +## how telegram bot stuff works + +### Let's start with the important bit: authentication. + +We wanted to find a good and secure way to authenticate users, or "link their supermemory account" to their telegram account. This was kinda challenging - because the requirements were tight and privacy was a big concern. + +1. No personally identifiable information should be stored, except the user's telegram ID and supermemory email. +2. The link should be as simple as a click of a button +3. it should work two-ways: If the user signs in to the website first, or uses the telegram bot first. +4. The user should be able to unlink their account at any time. +5. Should be very, very easy to host the telegram bot. + +We started out by trying to mingle with next-auth credentials provider - but that was a dead end. It would _work_, but would be too hard for us to implement and maintain, and would be a very bad user experience (get the token, copy it, paste it, etc). + +So we decided to go with a simple, yet secure, way of doing it. + +### the solution + +Well, the solution is simple af, surprisingly. To meet all these requirements, + +First off, we used the `grammy` library to create a telegram bot that works using websockets. (so, it's hosted with the website, and doesn't need a separate server) + +Now, let's examine both the flows: + +1. User signs in to the website first +2. Saves a bunch of stuff +3. wants to link their telegram account + +and... + +1. User uses the telegram bot first +2. Saves a bunch of stuff +3. wants to see their stuff in the supermemory account. + +What we ended up doing is creating a simple, yet secure way - always require signin through supermemory.ai website. +And if the user comes from the telegram bot, we just redirect them to the website with a token in the URL. + +The token. + +The token is literally just their telegram ID, but encrypted. We use a simple encryption algorithm to encrypt the telegram ID, and then decrypt it on the website. + +Why encryption? Because we don't want any random person to link any telegram account with their user id. The encryption is also interesting, done using an algorithm called [hushh](https://github.com/dhravya/hushh) that I made a while ago. It's simple and secure and all that's really needed is a secret key. + +Once the user signs in, we take the decrypted token and link it to their account. And that's it. The user can now use the telegram bot to access their stuff. Because it's on the same codebase on the server side, it's very easy to make database calls and also calls to the cf-ai-backend to generate stuff. + +### Natural language generation + +I wanted to add this: the bot actually does both - adding content and talking to the user - at the same time. + +How tho? +We use function calling in the backend repo smartly to decide what the user's intent would be. So, i can literally send the message "yo, can you remember this? (with anything else, can even be a URL!)" and the bot will understand that it's a command to add content. + +orr, i can send "hey, can you tell me about the time i went to the beach?" and the bot will understand that it's a command to get content. + +it's pretty cool. function calling using a cheap model works very well. diff --git a/apps/web/app/api/telegram/route.ts b/apps/web/app/api/telegram/route.ts index b80f6173..065a102a 100644 --- a/apps/web/app/api/telegram/route.ts +++ b/apps/web/app/api/telegram/route.ts @@ -1,3 +1,7 @@ +import { db } from "@/server/db"; +import { storedContent, users } from "@/server/db/schema"; +import { cipher, decipher } from "@/server/encrypt"; +import { eq } from "drizzle-orm"; import { Bot, webhookCallback } from "grammy"; import { User } from "grammy/types"; @@ -12,17 +16,96 @@ const token = process.env.TELEGRAM_BOT_TOKEN; const bot = new Bot(token); +const getUserByTelegramId = async (telegramId: string) => { + return await db.query.users + .findFirst({ + where: eq(users.telegramId, telegramId), + }) + .execute(); +}; + bot.command("start", async (ctx) => { const user: User = (await ctx.getAuthor()).user; + const cipherd = cipher(user.id.toString()); await ctx.reply( - `Welcome to Supermemory bot, ${user.first_name}. I am here to help you remember things better.`, + `Welcome to Supermemory bot. I am here to help you remember things better. Click here to create and link your accont: http://localhost:3000/signin?telegramUser=${cipherd}`, ); }); bot.on("message", async (ctx) => { - await ctx.reply( - "Hi there! This is Supermemory bot. I am here to help you remember things better.", + const user: User = (await ctx.getAuthor()).user; + const cipherd = cipher(user.id.toString()); + + const dbUser = await getUserByTelegramId(user.id.toString()); + + if (!dbUser) { + await ctx.reply( + `Welcome to Supermemory bot. I am here to help you remember things better. Click here to create and link your accont: http://localhost:3000/signin?telegramUser=${cipherd}`, + ); + + return; + } + + const message = await ctx.reply("I'm thinking..."); + + const response = await fetch( + `${process.env.BACKEND_BASE_URL}/api/autoChatOrAdd?query=${ctx.message.text}&user=${dbUser.id}`, + { + method: "POST", + headers: { + Authorization: "Bearer " + process.env.BACKEND_SECURITY_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + // TODO: we can use the conversations API to get the last 5 messages + // get chatHistory from this conversation. + // Basically the last 5 messages between the user and the assistant. + // In ths form of [{role: 'user' | 'assistant', content: string}] + // https://grammy.dev/plugins/conversations + chatHistory: [], + }), + }, ); + + if (response.status !== 200) { + console.log("Failed to get response from backend"); + console.log(response.status); + console.log(await response.text()); + await ctx.reply( + "Sorry, I am not able to process your request at the moment.", + ); + return; + } + + const data = (await response.json()) as { + status: string; + response: string; + contentAdded: { + type: string; + content: string; + url: string; + }; + }; + + // TODO: we might want to enrich this data with more information + if (data.contentAdded) { + await db + .insert(storedContent) + .values({ + content: data.contentAdded.content, + title: `${data.contentAdded.content.slice(0, 30)}... (Added from chatbot)`, + description: "", + url: data.contentAdded.url, + baseUrl: data.contentAdded.url, + image: "", + savedAt: new Date(), + userId: dbUser.id, + type: data.contentAdded.type, + }) + .returning({ id: storedContent.id }); + } + + await ctx.api.editMessageText(ctx.chat.id, message.message_id, data.response); }); export const POST = webhookCallback(bot, "std/http"); diff --git a/apps/web/server/encrypt.ts b/apps/web/server/encrypt.ts new file mode 100644 index 00000000..bb2f5050 --- /dev/null +++ b/apps/web/server/encrypt.ts @@ -0,0 +1,95 @@ +function convertStringToFixedNumber(input: string): number { + let hash = 0; + for (let i = 0; i < input.length; i++) { + const char = input.charCodeAt(i); + hash = (hash * 31 + char) % 1000000007; // Hashing by a large prime number + } + return hash; +} + +const chars = `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-1234567890`; +const shuffled = shuffle( + chars.split(""), + convertStringToFixedNumber(process.env.BACKEND_SECURITY_KEY), +); + +function random(seed: number) { + const x = Math.sin(seed++) * 10000; + return x - Math.floor(x); +} + +function shuffle(array: string[], seed: number) { + let m = array.length, + t, + i; + + while (m) { + i = Math.floor(random(seed) * m--); + + t = array[m]; + array[m] = array[i]!; + array[i] = t!; + ++seed; + } + + return array; +} + +export const cipher = (text: string) => { + let returned_text = ""; + + for (let i = 0; i < text.length; i++) { + returned_text += shuffled[chars.indexOf(text[i]!)]; + } + + return extend(returned_text); +}; + +export const decipher = (text: string) => { + let returned_text = ""; + const index = Math.floor( + random(convertStringToFixedNumber(process.env.BACKEND_SECURITY_KEY)) * + (text.length / 2), + ); + + for (let i = 0; i < text.length; i++) { + returned_text += chars[shuffled.indexOf(text[i]!)]; + } + const total = parseInt(text[index]!); + const str = parseInt(text.slice(index + 1, index + total + 1)); + return returned_text.slice(text.length - str); +}; + +const extend = (text: string, length = 60) => { + const extra = length - text.length; + + if (extra < 0) { + return text; + } + + // Random index to store the length of the string + const index = Math.floor( + random(convertStringToFixedNumber(process.env.BACKEND_SECURITY_KEY)) * + (length / 2), + ); + + const storage_string = + text.length.toString().length.toString() + text.length.toString(); + let returned = ""; + let total = storage_string.length + text.length; + + for (let i = 0; i < extra; i++) { + if (i == index) { + returned += storage_string; + } else { + if (total >= length) { + break; + } + // Add a random character + returned += shuffled[Math.floor(random(Math.random()) * shuffled.length)]; + total++; + } + } + returned += text; + return returned; +}; -- cgit v1.2.3