diff --git a/bun.lockb b/bun.lockb index ec83e5d..5bf3fb3 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 3aa7ae0..20c366a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "NODE_ENV=development bun --hot src/app.ts", "start": "bun src/app.ts", - "e2e-app-tests": "bun test app-crud --env-file=.env.test", + "e2e-app-tests": "bun test app-crud --env-file=.env.test --only", "e2e-env-tests": "bun test environment-crud --env-file=.env.test", "e2e-entity-tests": "bun test entity --env-file=.env.test", "e2e-tests-all": "bun test --env-file=.env.test" @@ -22,6 +22,9 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.20.6", + "@clerk/backend": "^1.2.1", + "@clerk/clerk-sdk-node": "^5.0.7", + "@hono/clerk-auth": "^2.0.0", "@langchain/openai": "^0.0.28", "assert": "^2.1.0", "axios": "^1.6.8", diff --git a/src/app.ts b/src/app.ts index d414aa7..f884c9a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,11 +1,15 @@ import { Hono } from "hono"; import { logger } from "hono/logger"; import appsRoute from "./routes/applications"; -import githubAuth from "./routes/auth/github"; -import uiAuthRoute from "./routes/examples/auth"; import ragRoute from "./routes/rag"; import searchRoute from "./routes/search"; import mongoConnect from "./connections/mongodb.ts"; +import webhooksRoute from "./routes/webhooks.ts"; +import { cors } from "hono/cors"; +import users from "./routes/users.ts"; +import { clerkMiddleware } from "@hono/clerk-auth"; +import contextMiddleware from "./middlewares/context.middleware.ts"; +import authMiddleware from "./middlewares/auth.middleware.ts"; const app = new Hono(); if (Bun.env.NODE_ENV === "development") { @@ -13,11 +17,25 @@ if (Bun.env.NODE_ENV === "development") { } await mongoConnect(); +app.use(contextMiddleware); +app.use( + cors({ + origin: [ + "https://7966-109-92-19-84.ngrok-free.app", + "http://localhost:5173", + ], + }), +); +app.route("/webhooks", webhooksRoute); + +app.use(clerkMiddleware()); +app.route("/users", users); + +app.use(authMiddleware); +// TODO auth middleware could be included here app.route("/apps", appsRoute); app.route("/search", searchRoute); app.route("/knowledgebase", ragRoute); -app.route("/auth/github", githubAuth); -app.route("/examples/auth", uiAuthRoute); export default app; diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index d376fea..f739be4 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -1,24 +1,41 @@ import { createFactory } from "hono/factory"; import { HTTPException } from "hono/http-exception"; -import { verify as jwt_verify } from "hono/jwt"; -import { bearerFromHeader } from "../utils/auth-utils"; +import { getAuth } from "@hono/clerk-auth"; +import { httpError } from "../utils/const.ts"; +import { findUserByClerkId } from "../services/user.service.ts"; const factory = createFactory(); const middleware = factory.createMiddleware(async (c, next) => { - const authorizationHeader = c.req.header("Authorization"); - const secret = Bun.env.JWT_SECRET; - if (!authorizationHeader || !secret) { - throw new HTTPException(401, { message: "Unauthorized request" }); + const auth = getAuth(c); + + if (!auth?.userId) { + throw new HTTPException(401, { + message: httpError.USER_NOT_AUTHENTICATED, + }); } - const bearerToken = bearerFromHeader(authorizationHeader); try { - const user = await jwt_verify(bearerToken, secret); + const user = await findUserByClerkId({ + id: auth.userId, + context: c.get("context"), + }); + + if (!user) { + throw new HTTPException(401, { + message: httpError.USER_NOT_AUTHENTICATED, + }); + } + c.set("user", user); await next(); - } catch (err) { - console.log({ err }); - throw new HTTPException(401, { message: "Unauthorized" }); + } catch (e) { + if (e instanceof HTTPException) { + throw e; + } else { + throw new HTTPException(500, { + message: httpError.UNKNOWN, + }); + } } }); diff --git a/src/models/user.model.ts b/src/models/user.model.ts index 8ce3e2f..2201ce4 100644 --- a/src/models/user.model.ts +++ b/src/models/user.model.ts @@ -3,6 +3,24 @@ import type { Application } from "./application.model"; const { Schema } = mongoose; +export const TelegramSchema = new Schema( + { + id: { type: Number, required: true, index: true, unique: true }, + appName: { type: String, required: true }, + envName: { type: String, required: true }, + }, + { _id: false }, +); + +export const WhatsappSchema = new Schema( + { + id: { type: String, required: true, index: true, unique: true }, + appName: { type: String, required: true }, + envName: { type: String, required: true }, + }, + { _id: false }, +); + export const UserSchema = new Schema({ email: { type: String, @@ -10,19 +28,30 @@ export const UserSchema = new Schema({ unique: true, index: true, }, - providers: { type: Array, default: [] }, applications: { type: Array, default: [] }, - lastProvider: { type: String, default: "" }, + clerkUserId: { type: String, required: true, unique: true }, lastUse: { type: Date, default: Date.now }, + telegram: { + type: TelegramSchema, + required: false, + }, + whatsapp: { + type: WhatsappSchema, + required: false, + }, }); export type User = { email: string; - providers: string[]; applications: Application[]; - lastProvider: string; lastUse: Date; -} + clerkUserId: string; + telegram?: { + id: number; + appName: string; + envName: string; + }; +}; const User = mongoose.model("User", UserSchema); export default User; diff --git a/src/repositories/interfaces.ts b/src/repositories/interfaces.ts index 0e7d036..2db7992 100644 --- a/src/repositories/interfaces.ts +++ b/src/repositories/interfaces.ts @@ -2,7 +2,7 @@ import { type Application } from "../models/application.model.ts"; import { type Environment } from "../models/environment.model.ts"; import { type Entity } from "../models/entity.model.ts"; import { type User } from "../models/user.model.ts"; -import type { EntityQueryMeta } from "../utils/types.ts"; +import type { EntityQueryMeta, TelegramSettings } from "../utils/types.ts"; import type { EntityAggregateResult } from "../services/entity.service.ts"; export interface IApplicationRepository { @@ -115,9 +115,16 @@ export interface IEntityRepository { export interface IUserRepository { createUser(props: { - provider: string; + clerkUserId: string; email: string; appName: string; }): Promise; - updateUser(props: { provider: string; email: string }): Promise; + updateUserLastUse(props: { clerkUserId: string }): Promise; + updateUserTelegramSettings(props: { + clerkUserId: string; + telegramSettings?: TelegramSettings; + }): Promise; + findUserByEmail(email: string): Promise; + findUserClerkId(id: string): Promise; + findUserByTelegramId(id: number): Promise; } diff --git a/src/repositories/mongodb/user.repository.ts b/src/repositories/mongodb/user.repository.ts index a2a8e19..4072b58 100644 --- a/src/repositories/mongodb/user.repository.ts +++ b/src/repositories/mongodb/user.repository.ts @@ -1,6 +1,8 @@ import BaseRepository from "./base-repository.ts"; import { type User } from "../../models/user.model.ts"; import type { IUserRepository } from "../interfaces.ts"; +import * as R from "ramda"; +import type { TelegramSettings } from "../../utils/types.ts"; class UserRepository extends BaseRepository implements IUserRepository { constructor() { @@ -8,38 +10,73 @@ class UserRepository extends BaseRepository implements IUserRepository { } public async createUser({ - provider, + clerkUserId, email, appName, }: { - provider: string; + clerkUserId: string; email: string; appName: string; }): Promise { - return this.userModel.create({ + const result = await this.userModel.create({ email, - providers: [provider], - lastProvider: provider, + clerkUserId, applications: [appName], }); + + // convert null values to undefined + return R.defaultTo(undefined, result) as User; } - public async updateUser({ - provider, - email, + public async updateUserLastUse({ + clerkUserId, }: { - provider: string; - email: string; + clerkUserId: string; }): Promise { return this.userModel.findOneAndUpdate( - { email }, + { clerkUserId }, { - $addToSet: { providers: provider }, - $set: { lastProvider: provider }, + lastUse: Date.now(), }, { returnNewDocument: true }, ); } + + public async updateUserTelegramSettings({ + clerkUserId, + telegramSettings, + }: { + clerkUserId: string; + telegramSettings?: TelegramSettings; + }): Promise { + const updateObj = telegramSettings?.telegramId + ? { + "telegram.id": telegramSettings.telegramId, + "telegram.appName": telegramSettings.appName, + "telegram.envName": telegramSettings.envName, + } + : { telegram: undefined }; + + return this.userModel.findOneAndUpdate( + { clerkUserId }, + { + ...updateObj, + }, + { returnNewDocument: true }, + ); + } + + public async findUserByEmail(email: string): Promise { + return this.userModel.findOne({ email }); + } + + public async findUserClerkId(id: string): Promise { + return this.userModel.findOne({ clerkUserId: id }); + } + + public async findUserByTelegramId(id: number): Promise { + return this.userModel.findOne({ "telegram.id": id }); + } } export default UserRepository; diff --git a/src/routes/applications.ts b/src/routes/applications.ts index f765ab1..4c2e309 100644 --- a/src/routes/applications.ts +++ b/src/routes/applications.ts @@ -1,6 +1,5 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; -import authMiddleware from "../middlewares/auth.middleware"; import { createApplication, deleteApplication, @@ -12,7 +11,6 @@ import type { USER_TYPE } from "../utils/auth-utils"; import { APPNAME_MIN_LENGTH, APPNAME_REGEX, httpError } from "../utils/const"; import { ServiceError } from "../utils/service-errors"; import envsRoute from "./environments"; -import contextMiddleware from "../middlewares/context.middleware.ts"; import type Context from "../middlewares/context.ts"; const app = new Hono<{ @@ -21,12 +19,9 @@ const app = new Hono<{ context: Context; }; }>(); -app.use(authMiddleware); -app.use(contextMiddleware); app.get("/all", async (c) => { const user = c.get("user"); - const apps = await getUserApplications({ context: c.get("context"), userEmail: user.email, diff --git a/src/routes/auth/github.ts b/src/routes/auth/github.ts deleted file mode 100644 index a92f81e..0000000 --- a/src/routes/auth/github.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Hono } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { sign as jwt_sign } from "hono/jwt"; -import { finalizeAuth, getGithubUserData } from "../../services/auth.service"; -import type { USER_TYPE } from "../../utils/auth-utils"; -import { PROVIDER_GITHUB } from "../../utils/const"; -import contextMiddleware from "../../middlewares/context.middleware.ts"; -import type Context from "../../middlewares/context.ts"; - -const app = new Hono<{ - Variables: { - context: Context; - }; -}>(); - -app.use(contextMiddleware); - -app.get("/", async (c) => { - const jwtSecret = Bun.env.JWT_SECRET; - if (!jwtSecret) { - throw new HTTPException(400, { - message: "Env is missing", - }); - } - const code = c.req.query("code"); - const githubRedirectUrl = Bun.env.GITHUB_CALLBACK_URL; - if (!code || !githubRedirectUrl) { - throw new HTTPException(400, { - message: "Authorization code or redirect URL is missing in the request", - }); - } - try { - const userData: USER_TYPE = await getGithubUserData({ - redirectUrl: githubRedirectUrl, - code: code, - }); - if (!userData.email) { - throw new HTTPException(500, { - message: "Couldn't store user data", - }); - } - const jwtToken = await jwt_sign(userData, jwtSecret); - c.res.headers.set("Authorization", `Bearer ${jwtToken}`); - await finalizeAuth({ - context: c.get("context"), - email: userData.email, - provider: PROVIDER_GITHUB, - }); - return c.json({ userData }); - } catch (e: any) { - console.log(e.message); - throw new HTTPException(500, { - message: "Unknown error occured", - }); - } -}); - -export default app; diff --git a/src/routes/auth/google.ts b/src/routes/auth/google.ts deleted file mode 100644 index b7db69a..0000000 --- a/src/routes/auth/google.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Hono } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { sign as jwt_sign } from "hono/jwt"; -import { - getGoogleLoginUrl, - getGoogleUserData, -} from "../../services/auth.service"; -import type Context from "../../middlewares/context.ts"; -import contextMiddleware from "../../middlewares/context.middleware.ts"; - -const app = new Hono<{ - Variables: { - context: Context; - }; -}>(); -app.use(contextMiddleware); - -app.get("/:db", async (c) => { - const redirectUrl = c.req.query("redirectUrl") || Bun.env.GOOGLE_REDIRECT_URI; - if (!redirectUrl) { - throw new HTTPException(400, { - message: "Redirect url missing in request", - }); - } - const loginUrl = getGoogleLoginUrl({ redirectUrl }); - return c.json({ loginUrl }); -}); - -app.post("/:db", async (c) => { - const { JWT_SECRET } = Bun.env; - if (!JWT_SECRET) { - throw new HTTPException(400, { - message: "Env is missing", - }); - } - const body = await c.req.json(); - if (!body.code || !body.redirectUrl) { - throw new HTTPException(400, { - message: "Authorization code or redirect URL is missing in the request", - }); - } - - try { - const userData = await getGoogleUserData({ - redirectUrl: body.redirectUrl, - code: body.code, - context: c.get("context"), - }); - const jwtToken = await jwt_sign(userData, JWT_SECRET); - c.res.headers.set("Authorization", `Bearer ${jwtToken}`); - return c.json({ - status: true, - message: "Login Success", - ...userData, - }); - } catch (error) { - throw new HTTPException(500, { - message: "Failed to set user data", - }); - } -}); diff --git a/src/routes/environments.ts b/src/routes/environments.ts index fe2fdac..38add55 100644 --- a/src/routes/environments.ts +++ b/src/routes/environments.ts @@ -1,6 +1,5 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; -import authMiddleware from "../middlewares/auth.middleware"; import { createEnvironment, deleteEnvironment, @@ -12,7 +11,6 @@ import { httpError } from "../utils/const"; import { asyncTryJson } from "../utils/route-utils"; import { ServiceError } from "../utils/service-errors"; import entitiesRoute from "./entities"; -import contextMiddleware from "../middlewares/context.middleware.ts"; import type Context from "../middlewares/context.ts"; const app = new Hono<{ @@ -21,8 +19,6 @@ const app = new Hono<{ context: Context; }; }>(); -app.use(authMiddleware); -app.use(contextMiddleware); app.get("/", async (c) => { const { appName, envName } = c.req.param() as { diff --git a/src/routes/examples/auth.ts b/src/routes/examples/auth.ts deleted file mode 100644 index c538ed0..0000000 --- a/src/routes/examples/auth.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Hono } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { getGithubLoginUrl } from "../../services/auth.service"; -import { Layout } from "./components/Layout"; - -const app = new Hono(); - -app.get("/", (c) => { - const githubRedirectUrl = Bun.env.GITHUB_CALLBACK_URL; - if (!githubRedirectUrl) { - throw new HTTPException(400, { - message: "Missing Github redirect url", - }); - } - const githubLoginUrl = getGithubLoginUrl({ - redirectUrl: githubRedirectUrl, - }); - return c.html(Layout({ githubLoginUrl, googleLoginUrl: "" })); -}); - -export default app; diff --git a/src/routes/examples/components/Layout.tsx b/src/routes/examples/components/Layout.tsx deleted file mode 100644 index c9bee50..0000000 --- a/src/routes/examples/components/Layout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { html } from "hono/html"; - -export const Layout = ({ - googleLoginUrl, - githubLoginUrl, -}: { - googleLoginUrl: string; - githubLoginUrl: string; -}) => { - return html` - - - - - Title - - - - - `; -}; diff --git a/src/routes/rag.ts b/src/routes/rag.ts index 31ebcbb..06c50ea 100644 --- a/src/routes/rag.ts +++ b/src/routes/rag.ts @@ -1,11 +1,9 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import * as R from "ramda"; -import authMiddleware from "../middlewares/auth.middleware"; import { searchAiEntities } from "../services/entity.service"; import type { USER_TYPE } from "../utils/auth-utils"; import { ServiceError } from "../utils/service-errors"; -import contextMiddleware from "../middlewares/context.middleware.ts"; import type Context from "../middlewares/context.ts"; const app = new Hono<{ @@ -14,8 +12,6 @@ const app = new Hono<{ context: Context; }; }>(); -app.use(authMiddleware); -app.use(contextMiddleware); app.post("/:app/:env/*", async (c) => { const { app, env } = c.req.param(); diff --git a/src/routes/search.ts b/src/routes/search.ts index 5f5191c..e856f73 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -1,11 +1,9 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import * as R from "ramda"; -import authMiddleware from "../middlewares/auth.middleware"; import { searchEntities } from "../services/entity.service"; import type { USER_TYPE } from "../utils/auth-utils"; import { httpError } from "../utils/const"; -import contextMiddleware from "../middlewares/context.middleware.ts"; import type Context from "../middlewares/context.ts"; const app = new Hono<{ @@ -14,8 +12,6 @@ const app = new Hono<{ context: Context; }; }>(); -app.use(authMiddleware); -app.use(contextMiddleware); app.post("/:app/:env/*", async (c) => { const { app, env } = c.req.param(); diff --git a/src/routes/users.ts b/src/routes/users.ts new file mode 100644 index 0000000..51dd162 --- /dev/null +++ b/src/routes/users.ts @@ -0,0 +1,59 @@ +import { Hono } from "hono"; +import { getAuth } from "@hono/clerk-auth"; +import type Context from "../middlewares/context.ts"; +import { type ClerkClient } from "@clerk/backend"; +import { HTTPException } from "hono/http-exception"; +import { httpError } from "../utils/const.ts"; +import { createOrFetchUser } from "../services/user.service.ts"; +import telegramRoute from "./users/telegram.ts"; +import authMiddleware from "../middlewares/auth.middleware.ts"; +import { type User } from "../models/user.model.ts"; + +const app = new Hono<{ + Variables: { + context: Context; + clerk: ClerkClient; + user: User; + }; +}>(); + +// TODO e2e tests +app.post("/auth", async (c) => { + const clerkClient = c.get("clerk"); + const auth = getAuth(c); + + if (!auth?.userId) { + throw new HTTPException(401, { + message: httpError.USER_NOT_AUTHENTICATED, + }); + } + + const clerkUser = await clerkClient.users.getUser(auth.userId); + if (!clerkUser) { + throw new HTTPException(401, { + message: httpError.USER_NOT_AUTHENTICATED, + }); + } + + try { + const user = await createOrFetchUser({ + user: clerkUser, + context: c.get("context"), + }); + return c.json(user); + } catch (e) { + throw new HTTPException(500, { + message: httpError.UNKNOWN, + }); + } +}); + +app.use(authMiddleware); + +app.get("/profile", async (c) => { + return c.json(c.get("user")); +}); + +app.route("/settings/telegram", telegramRoute); + +export default app; diff --git a/src/routes/users/telegram.ts b/src/routes/users/telegram.ts new file mode 100644 index 0000000..893be05 --- /dev/null +++ b/src/routes/users/telegram.ts @@ -0,0 +1,41 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type Context from "../../middlewares/context.ts"; +import type { USER_TYPE } from "../../utils/auth-utils.ts"; +import { updateUserTelegramSettings } from "../../services/user.service.ts"; +import { ServiceError } from "../../utils/service-errors.ts"; +import type { TelegramSettings } from "../../utils/types.ts"; + +const app = new Hono<{ + Variables: { + user: USER_TYPE; + context: Context; + }; +}>(); + +// TODO cover with e2e tests +app.patch("/", async (c) => { + try { + const body = (await c.req.json()) as TelegramSettings; + + await updateUserTelegramSettings({ + telegramSettings: body, + clerkUserId: c.get("user").clerkUserId, + context: c.get("context"), + }); + return c.json({ done: true }); + } catch (e: any) { + if (e instanceof HTTPException) { + throw e; + } else if (e instanceof ServiceError) { + throw new HTTPException(400, { message: e.explicitMessage }); + } else { + console.log(e.message); + throw new HTTPException(500, { + message: "Unknown error occured", + }); + } + } +}); + +export default app; diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts new file mode 100644 index 0000000..08189bd --- /dev/null +++ b/src/routes/webhooks.ts @@ -0,0 +1,10 @@ +import { Hono } from "hono"; +import whatsappRoute from "./webhooks/whatsapp.ts"; +import telegramRoute from "./webhooks/telegram.ts"; + +const app = new Hono(); + +app.route("/whatsapp", whatsappRoute); +app.route("/telegram", telegramRoute); + +export default app; diff --git a/src/routes/webhooks/telegram.ts b/src/routes/webhooks/telegram.ts new file mode 100644 index 0000000..b8538be --- /dev/null +++ b/src/routes/webhooks/telegram.ts @@ -0,0 +1,44 @@ +import { Hono } from "hono"; +import { + handleWebhookMessage, + type TelegramRequest, +} from "../../services/telegram.service.ts"; +import type { USER_TYPE } from "../../utils/auth-utils.ts"; +import type Context from "../../middlewares/context.ts"; +import { ServiceError } from "../../utils/service-errors.ts"; +import { HTTPException } from "hono/http-exception"; +import { httpError } from "../../utils/const.ts"; + +const app = new Hono<{ + Variables: { + user: USER_TYPE; + context: Context; + }; +}>(); + +// validation endpoint which is needed +app.get("/", async (c) => { + const query = c.req.query(); + console.log("query", query); + return c.text("true"); +}); + +app.post("/", async (c) => { + const body = (await c.req.json()) as TelegramRequest; + try { + await handleWebhookMessage({ + telegramRequestBody: body, + context: c.get("context"), + user: c.get("user"), + }); + return c.json({}); + } catch (error) { + if (error instanceof ServiceError) { + throw new HTTPException(400, { message: error.explicitMessage }); + } else { + throw new HTTPException(500, { message: httpError.UNKNOWN }); + } + } +}); + +export default app; diff --git a/src/routes/webhooks/whatsapp.ts b/src/routes/webhooks/whatsapp.ts new file mode 100644 index 0000000..7ac4b78 --- /dev/null +++ b/src/routes/webhooks/whatsapp.ts @@ -0,0 +1,24 @@ +import { Hono } from "hono"; +import { + handleWebhookMessage, + type WebhookMessage, +} from "../../services/whatsapp.service.ts"; + +const app = new Hono(); + +// TODO middleware to validate token +// TODO When and how do we attach the user and the users apps and permissions + +// validation endpoint which is needed +app.get("/", async (c) => { + const query = c.req.query(); + return c.text(query["hub.challenge"]); +}); + +app.post("/", async (c) => { + const body = (await c.req.json()) as WebhookMessage; + await handleWebhookMessage(body); + return c.json({}); +}); + +export default app; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts deleted file mode 100644 index 0e77ee1..0000000 --- a/src/services/auth.service.ts +++ /dev/null @@ -1,157 +0,0 @@ -import axios from "axios"; -import { HTTPException } from "hono/http-exception"; -import { decode as jwt_decode } from "hono/jwt"; -import * as R from "ramda"; -import generateAppName from "../utils/app-name"; -import { generateState } from "../utils/auth-utils"; -import { - defaultNodbEnv, - ENVIRONMENT_MONGO_DB_REPOSITORY, - PROVIDER_GOOGLE, - USER_MONGO_DB_REPOSITORY, -} from "../utils/const"; -import type Context from "../middlewares/context.ts"; -import { EnvironmentRepository, UserRepository } from "../repositories/mongodb"; -import { type User } from "../models/user.model.ts"; - -export const getGithubLoginUrl = ({ redirectUrl }: { redirectUrl: string }) => { - const { GITHUB_CLIENT_ID, GITHUB_AUTH_ENDPOINT } = Bun.env; - if (!GITHUB_CLIENT_ID || !GITHUB_AUTH_ENDPOINT) { - throw new HTTPException(400, { - message: "Missing Github env", - }); - } - const params = new URLSearchParams(); - params.append("client_id", GITHUB_CLIENT_ID); - params.append("redirect_uri", redirectUrl); - params.append("scope", ["read:user", "user:email"].join(" ")); - params.append("allow_signup", "true"); - return `${process.env.GITHUB_AUTH_ENDPOINT}?${params.toString()}`; -}; - -export const getGoogleLoginUrl = ({ redirectUrl }: { redirectUrl: string }) => { - const { GOOGLE_RESPONSE_TYPE, GOOGLE_CLIENT_ID, GOOGLE_SCOPE } = Bun.env; - if (!GOOGLE_RESPONSE_TYPE || !GOOGLE_CLIENT_ID || !GOOGLE_SCOPE) { - throw new HTTPException(400, { - message: "Missing Google env", - }); - } - const state = generateState(); - const params = new URLSearchParams(); - params.append("client_id", GOOGLE_CLIENT_ID); - params.append("response_type", GOOGLE_RESPONSE_TYPE); - params.append("redirect_uri", redirectUrl); - params.append("scope", GOOGLE_SCOPE); - params.append("state", state); - return `${process.env.GOOGLE_AUTH_ENDPOINT}?${params.toString()}`; -}; - -export const finalizeAuth = async ({ - context, - email, - provider, -}: { - context: Context; - email: string; - provider: string; -}): Promise => { - const userRepository = context.get(USER_MONGO_DB_REPOSITORY); - const updatedUser = await userRepository.updateUser({ email, provider }); - - if (updatedUser) return updatedUser; - - const appName = generateAppName(); - const environmentRepository = context.get( - ENVIRONMENT_MONGO_DB_REPOSITORY, - ); - - await environmentRepository.createEnvironment({ - appName, - envName: defaultNodbEnv, - }); - - return userRepository.createUser({ provider, email, appName }); -}; - -export const getGoogleUserData = async ({ - redirectUrl, - code, - context, -}: { - redirectUrl: string; - code: string; - context: Context; -}) => { - const { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET } = Bun.env; - if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) { - throw new HTTPException(400, { - message: "Missing Google access env", - }); - } - const params = new URLSearchParams(); - params.append("grant_type", "authorization_code"); - params.append("code", decodeURIComponent(code)); - params.append("redirect_uri", redirectUrl); - params.append("client_id", GOOGLE_CLIENT_ID); - params.append("client_secret", GOOGLE_CLIENT_SECRET); - const options = { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - data: params.toString(), - url: process.env.GOOGLE_TOKEN_ENDPOINT, - }; - const response = await axios(options); - if (response.status !== 200) { - throw new HTTPException(400, { - message: "Error while getting Google response", - }); - } - const { id_token } = response.data; - const email = R.path(["payload", "email"], jwt_decode(id_token)); - return finalizeAuth({ context, email, provider: PROVIDER_GOOGLE }); -}; - -export const getGithubUserData = async ({ - redirectUrl, - code, -}: { - redirectUrl: string; - code: string; -}) => { - const { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } = Bun.env; - if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET) { - throw new HTTPException(400, { - message: "Missing Github access env", - }); - } - const params = new URLSearchParams(); - params.append("grant_type", "authorization_code"); - params.append("code", code); - params.append("redirect_uri", redirectUrl); - params.append("client_id", GITHUB_CLIENT_ID); - params.append("client_secret", GITHUB_CLIENT_SECRET); - const githubTokenOptions = { - method: "POST", - data: params.toString(), - url: Bun.env.GITHUB_TOKEN_ENDPOINT, - }; - const githubTokenResponse = await axios(githubTokenOptions); - if (githubTokenResponse.status !== 200) { - throw new HTTPException(400, { - message: "Error while getting Github response", - }); - } - const access_token = githubTokenResponse.data.split("&")[0].split("=")[1]; - const githubUserInfoOptions = { - method: "GET", - headers: { Authorization: `Bearer ${access_token}` }, - url: process.env.GITHUB_USERINFO_ENDPOINT, - }; - const githubUserResponse = await axios(githubUserInfoOptions); - if (githubUserResponse.status !== 200) { - throw new HTTPException(400, { - message: "Error while getting Github response", - }); - } - return githubUserResponse.data[0]; -}; diff --git a/src/services/telegram.service.ts b/src/services/telegram.service.ts new file mode 100644 index 0000000..2c36a2c --- /dev/null +++ b/src/services/telegram.service.ts @@ -0,0 +1,80 @@ +import { searchAiEntities } from "./entity.service.ts"; +import type Context from "../middlewares/context.ts"; +import { type User } from "../models/user.model.ts"; +import { httpError } from "../utils/const.ts"; +import { ServiceError } from "../utils/service-errors.ts"; +import { findUserByTelegramId } from "./user.service.ts"; + +const telegramBaseUrl = `https://api.telegram.org/bot${Bun.env.TELEGRAM_TOKEN}`; + +interface TelegramRequest { + update_id: number; + message: TelegramMessage; +} + +interface TelegramMessage { + message_id: number; + from: { + id: number; + is_bot: false; + first_name: string; + last_name: string; + language_code: string; + }; + chat: { + id: number; + first_name: string; + last_name: string; + type: string; + }; + date: number; + text: string; +} + +const handleWebhookMessage = async ({ + telegramRequestBody, + context, +}: { + telegramRequestBody: TelegramRequest; + context: Context; + user: User; +}): Promise => { + const { message } = telegramRequestBody; + + if (!Bun.env.TELEGRAM_TOKEN) { + return; + } + + const userTelegramId = message.from.id; + const user = await findUserByTelegramId({ id: userTelegramId, context }); + + if (!user || !user.telegram) { + throw new ServiceError(httpError.USER_NOT_FOUND); + } + + const appFilter = `${user.telegram.appName}/${user.telegram.envName}`; + + const res = await searchAiEntities({ + context, + query: message.text, + entityType: appFilter, + }); + + if (!res) { + throw new ServiceError(httpError.UNKNOWN); + } + + await fetch(`${telegramBaseUrl}/sendMessage`, { + method: "POST", + headers: { + "Content-type": "application/json", + }, + body: JSON.stringify({ + chat_id: message.chat.id, + text: res.answer as string, + }), + }); +}; + +export { handleWebhookMessage }; +export type { TelegramRequest }; diff --git a/src/services/user.service.ts b/src/services/user.service.ts new file mode 100644 index 0000000..baf5070 --- /dev/null +++ b/src/services/user.service.ts @@ -0,0 +1,117 @@ +import type { User } from "../models/user.model.ts"; +import type Context from "../middlewares/context.ts"; +import { + defaultNodbEnv, + ENVIRONMENT_MONGO_DB_REPOSITORY, + httpError, + USER_MONGO_DB_REPOSITORY, +} from "../utils/const.ts"; +import type { IUserRepository } from "../repositories/interfaces.ts"; +import { ServiceError } from "../utils/service-errors.ts"; +import { type User as ClerkUser } from "@clerk/backend"; +import generateAppName from "../utils/app-name.ts"; +import { EnvironmentRepository } from "../repositories/mongodb"; +import type { TelegramSettings } from "../utils/types.ts"; +import * as R from "ramda"; + +const updateUserTelegramSettings = async ({ + telegramSettings, + context, + clerkUserId, +}: { + telegramSettings: TelegramSettings; + clerkUserId: string; + context: Context; +}): Promise => { + if (R.keys(R.filter(R.isNil, telegramSettings)).length === 0) { + throw new ServiceError(httpError.USER_TELEGRAM_SETTINGS_MISSING); + } + + const repository = context.get(USER_MONGO_DB_REPOSITORY); + const user = await repository.updateUserTelegramSettings({ + clerkUserId, + telegramSettings, + }); + if (!user) { + throw new ServiceError(httpError.USER_NOT_FOUND); + } + + return user; +}; + +const findUserByEmail = async ({ + email, + context, +}: { + email: string; + context: Context; +}): Promise => { + const repository = context.get(USER_MONGO_DB_REPOSITORY); + return repository.findUserByEmail(email); +}; + +const findUserByClerkId = async ({ + id, + context, +}: { + id: string; + context: Context; +}): Promise => { + const repository = context.get(USER_MONGO_DB_REPOSITORY); + return repository.findUserClerkId(id); +}; + +const findUserByTelegramId = async ({ + id, + context, +}: { + id: number; + context: Context; +}): Promise => { + const repository = context.get(USER_MONGO_DB_REPOSITORY); + return repository.findUserByTelegramId(id); +}; + +const createOrFetchUser = async ({ + user, + context, +}: { + user: ClerkUser; + context: Context; +}): Promise => { + const userEmail = user.primaryEmailAddress?.emailAddress; + + if (!userEmail) { + throw new ServiceError(httpError.USER_DOES_NOT_HAVE_EMAIL); + } + + const dbUser = await findUserByClerkId({ id: user.id, context }); + + if (dbUser) { + return dbUser; + } + const repository = context.get(USER_MONGO_DB_REPOSITORY); + const appName = generateAppName(); + const environmentRepository = context.get( + ENVIRONMENT_MONGO_DB_REPOSITORY, + ); + + await environmentRepository.createEnvironment({ + appName, + envName: defaultNodbEnv, + }); + + return repository.createUser({ + clerkUserId: user.id, + email: userEmail, + appName, + }); +}; + +export { + updateUserTelegramSettings, + createOrFetchUser, + findUserByEmail, + findUserByClerkId, + findUserByTelegramId, +}; diff --git a/src/services/whatsapp.service.ts b/src/services/whatsapp.service.ts new file mode 100644 index 0000000..a8fd33f --- /dev/null +++ b/src/services/whatsapp.service.ts @@ -0,0 +1,95 @@ +interface WebhookMessage { + object: string; + entry: WebhookMessageEntry[]; +} + +interface WebhookMessageEntry { + id: string; + changes: WebhookChanges[]; +} + +interface WebhookChanges { + value: { + messaging_product: string; + metadata: { + display_phone_number: string; + phone_number_id: string; + }; + contacts: { profile: { name: string }; wa_id: string }[]; + messages: WebhookMessageReceive[]; + }; + field: string; +} + +interface WebhookMessageReceive { + from: string; + to: string; + timestamp: string; + text: { body: string }; + type: string; +} + +interface WebhookMessageSend { + messaging_product: "whatsapp"; + recipient_type: "individual"; + to: string; + type: "text"; + text: { + body: string; + }; +} + +const handleWebhookMessage = async (message: WebhookMessage): Promise => { + const { entry } = message; + + if ( + !Bun.env.WA_TOKEN || + !Bun.env.WA_BUSINESS_PHONE_NUMBER_ID || + entry[0].changes[0].field !== "messages" || + !entry[0].changes[0].value.messages + ) { + return; + } + + const phoneNumber = entry[0].changes[0].value.messages[0].from; + const messageText = entry[0].changes[0].value.messages[0].text.body; + console.log(messageText); + + const response = await fetch( + `https://graph.facebook.com/v19.0/${Bun.env.WA_BUSINESS_PHONE_NUMBER_ID}/messages`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${Bun.env.WA_TOKEN}`, + }, + body: JSON.stringify( + getTextMessage({ message: "Hello from Hono app", phoneNumber }), + ), + }, + ); + + console.log("response", response.status); + console.log("response", await response.json()); +}; + +function getTextMessage({ + message, + phoneNumber, +}: { + message: string; + phoneNumber: string; +}): WebhookMessageSend { + return { + messaging_product: "whatsapp", + recipient_type: "individual", + to: phoneNumber, + type: "text", + text: { + body: message, + }, + }; +} + +export { handleWebhookMessage }; +export type { WebhookMessage }; diff --git a/src/utils/auth-utils.ts b/src/utils/auth-utils.ts index 4a2e942..a72ef5c 100644 --- a/src/utils/auth-utils.ts +++ b/src/utils/auth-utils.ts @@ -1,10 +1,8 @@ import crypto from "crypto"; +import { type User } from "../models/user.model.ts"; -export type USER_TYPE = { - email: string; - applications: []; - lastProvider: string; -}; +// TODO remove this type and use User everywhere +export type USER_TYPE = User; export const generateState = (length = 32) => { const randomBytes = crypto.randomBytes(length); diff --git a/src/utils/const.ts b/src/utils/const.ts index c4da420..e86d7f7 100644 --- a/src/utils/const.ts +++ b/src/utils/const.ts @@ -59,6 +59,11 @@ export const embeddingProvider: EmbeddingProvider = export const httpError = { USER_CANT_CREATE: "Couldn't create user", + USER_NOT_FOUND: "Can't find user", + USER_NOT_AUTHENTICATED: "Unauthenticated", + USER_DOES_NOT_HAVE_EMAIL: "User does not have a linked email", + USER_TELEGRAM_SETTINGS_MISSING: + "User does not have all required settings for setting up telegram", ENV_CANT_CREATE: "Couldn't create environment", APPNAME_LENGTH: `Application name length must be greater than ${APPNAME_MIN_LENGTH}`, APPNAME_NOT_ALLOWED: diff --git a/src/utils/types.ts b/src/utils/types.ts index b4bff72..3ef1c26 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -43,3 +43,9 @@ export type Pagination = { previous_page?: string; next_page?: string; }; + +export type TelegramSettings = { + telegramId: number; + appName: string; + envName: string; +};