diff --git a/package.json b/package.json index fa967bb2..5616cf0b 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "cmdk": "^1.1.1", "cookies-next": "^6.0.0", "date-fns": "^4.1.0", + "etsy-ts": "^4.2.0", "discord-api-types": "^0.38.17", "exa-js": "^1.8.8", "fast-deep-equal": "^3.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54b46f29..9cafcd36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + etsy-ts: + specifier: ^4.2.0 + version: 4.2.0 discord-api-types: specifier: ^0.38.17 version: 0.38.18 @@ -2495,6 +2498,11 @@ packages: resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} engines: {node: '>=4'} + axios-auth-refresh@3.3.6: + resolution: {integrity: sha512-2CeBUce/SxIfFxow5/n8vApJ97yYF6qoV4gh1UrswT7aEOnlOdBLxxyhOI4IaxGs6BY0l8YujU2jlc4aCmK17Q==} + peerDependencies: + axios: '>= 0.18 < 0.19.0 || >= 0.19.1' + axios@1.7.7: resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} @@ -3186,6 +3194,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + etsy-ts@4.2.0: + resolution: {integrity: sha512-YrVSiIP1s1FE7/isCnnkUR86te4+UABHfW7gWUsAHUDtPZj2NOw4VswabcfWxbtis+8pmZm7XQO0cQs8LZL+Fw==} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -7794,6 +7805,10 @@ snapshots: axe-core@4.10.3: {} + axios-auth-refresh@3.3.6(axios@1.7.7): + dependencies: + axios: 1.7.7 + axios@1.7.7: dependencies: follow-redirects: 1.15.9 @@ -8655,6 +8670,15 @@ snapshots: etag@1.8.1: {} + etsy-ts@4.2.0: + dependencies: + axios: 1.7.7 + axios-auth-refresh: 3.3.6(axios@1.7.7) + form-data: 4.0.3 + tslib: 2.8.1 + transitivePeerDependencies: + - debug + event-target-shim@5.0.1: {} eventemitter3@4.0.7: {} diff --git a/src/app/_components/navbar/account-button/provider-icon.tsx b/src/app/_components/navbar/account-button/provider-icon.tsx index 57c10e8c..a94ba0b1 100644 --- a/src/app/_components/navbar/account-button/provider-icon.tsx +++ b/src/app/_components/navbar/account-button/provider-icon.tsx @@ -7,6 +7,7 @@ import { SiX, SiSpotify, SiStrava, + SiEtsy, } from "@icons-pack/react-simple-icons"; interface Props { @@ -40,6 +41,7 @@ export const AuthProviderIcon: React.FC = ({ provider, className }) => { Notion: SiNotion, Spotify: SiSpotify, Strava: SiStrava, + Etsy: SiEtsy, }[provider] ?? null; return Icon ? : null; diff --git a/src/env.js b/src/env.js index 927ed7f9..55ec3477 100644 --- a/src/env.js +++ b/src/env.js @@ -39,6 +39,10 @@ const createAuthSchema = () => { authSchema.AUTH_SPOTIFY_SECRET = z.string(); } + if (process.env.AUTH_ETSY_ID) { + authSchema.AUTH_ETSY_ID = z.string(); + } + return authSchema; }; diff --git a/src/server/api/routers/accounts.ts b/src/server/api/routers/accounts.ts index b4f28077..bb6043ca 100644 --- a/src/server/api/routers/accounts.ts +++ b/src/server/api/routers/accounts.ts @@ -73,4 +73,30 @@ export const accountsRouter = createTRPCRouter({ }, }); }), + + updateAccount: protectedProcedure + .input( + z.object({ + provider: z.string(), + providerAccountId: z.string(), + data: z.object({ + access_token: z.string(), + refresh_token: z.string(), + expires_at: z.number(), + token_type: z.string(), + scope: z.string(), + }), + }), + ) + .mutation(async ({ ctx, input: { provider, providerAccountId, data } }) => { + return ctx.db.account.update({ + where: { + provider_providerAccountId: { + provider, + providerAccountId, + }, + }, + data, + }); + }), }); diff --git a/src/server/auth/custom-providers/etsy.ts b/src/server/auth/custom-providers/etsy.ts new file mode 100644 index 00000000..2358607b --- /dev/null +++ b/src/server/auth/custom-providers/etsy.ts @@ -0,0 +1,66 @@ +import { env } from "@/env"; + +import type { OAuth2Config, OAuthUserConfig } from "next-auth/providers"; + +export interface EtsyProfile { + user_id: number; + primary_email?: string | null; + first_name?: string | null; + last_name?: string | null; + image_url_75x75?: string | null; +} + +export const etsyScopes = "email_r shops_r listings_r"; + +export default function EtsyProvider

( + options: OAuthUserConfig

, +): OAuth2Config

{ + return { + id: "etsy", + name: "Etsy", + type: "oauth", + clientId: options.clientId, + clientSecret: options.clientId, + authorization: { + url: "https://www.etsy.com/oauth/connect", + params: { + scope: etsyScopes, + state: Math.random().toString(36).substring(2, 15), + }, + }, + token: { + url: "https://openapi.etsy.com/v3/public/oauth/token", + params: { + client_id: env.AUTH_ETSY_ID, + }, + }, + client: { token_endpoint_auth_method: "none" }, + userinfo: { + url: "https://openapi.etsy.com/v3/application/users/me", + async request({ tokens }: { tokens: { access_token: string } }) { + const userId = parseInt(tokens.access_token.split(".")[0]!); + + const response = await fetch( + `https://api.etsy.com/v3/application/users/${userId}`, + { + headers: { + "x-api-key": env.AUTH_ETSY_ID, + Authorization: `Bearer ${tokens.access_token}`, + }, + }, + ); + + return (await response.json()) as EtsyProfile; + }, + }, + profile(profile) { + return { + id: profile.user_id.toString(), + name: profile.first_name, + email: profile.primary_email, + image: profile.image_url_75x75, + }; + }, + options, + }; +} diff --git a/src/server/auth/providers.ts b/src/server/auth/providers.ts index 816cba01..b152ba91 100644 --- a/src/server/auth/providers.ts +++ b/src/server/auth/providers.ts @@ -14,14 +14,16 @@ import SpotifyProvider, { } from "next-auth/providers/spotify"; import StravaProvider, { type StravaProfile } from "next-auth/providers/strava"; import CredentialsProvider from "next-auth/providers/credentials"; +import EtsyProvider, { type EtsyProfile } from "./custom-providers/etsy"; + +import { IS_DEVELOPMENT } from "@/lib/constants"; +import { db } from "../db"; import type { CredentialInput, CredentialsConfig, OAuthConfig, } from "next-auth/providers"; -import { IS_DEVELOPMENT } from "@/lib/constants"; -import { db } from "../db"; export const providers: ( | OAuthConfig @@ -31,6 +33,7 @@ export const providers: ( | OAuthConfig | OAuthConfig | OAuthConfig + | OAuthConfig | CredentialsConfig> )[] = [ ...("AUTH_GOOGLE_ID" in env && "AUTH_GOOGLE_SECRET" in env @@ -121,6 +124,13 @@ export const providers: ( }), ] : []), + ...("AUTH_ETSY_ID" in env + ? [ + EtsyProvider({ + clientId: env.AUTH_ETSY_ID, + }), + ] + : []), ...(IS_DEVELOPMENT ? [ CredentialsProvider({ diff --git a/src/toolkits/toolkits/Etsy/base.ts b/src/toolkits/toolkits/Etsy/base.ts new file mode 100644 index 00000000..d585bf7d --- /dev/null +++ b/src/toolkits/toolkits/Etsy/base.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { EtsyTools } from "./tools/tools"; + +import { getListings } from "@/toolkits/toolkits/etsy/tools/get-listings/base"; + +import type { ToolkitConfig } from "@/toolkits/types"; + +export const etsyParameters = z.object({}); + +export const baseEtsyToolkitConfig: ToolkitConfig< + EtsyTools, + typeof etsyParameters.shape +> = { + tools: { + [EtsyTools.getListings]: getListings, + }, + parameters: etsyParameters, +}; diff --git a/src/toolkits/toolkits/Etsy/client.tsx b/src/toolkits/toolkits/Etsy/client.tsx new file mode 100644 index 00000000..dc79b09b --- /dev/null +++ b/src/toolkits/toolkits/Etsy/client.tsx @@ -0,0 +1,41 @@ +import { SiEtsy } from "@icons-pack/react-simple-icons"; + +import { EtsyWrapper } from "./wrapper"; + +import { Link } from "../components/link"; + +import { baseEtsyToolkitConfig } from "./base"; + +import { createClientToolkit } from "@/toolkits/create-toolkit"; + +import { getListingsClientConfig } from "@/toolkits/toolkits/etsy/tools/get-listings/client"; + +import { ToolkitGroups } from "@/toolkits/types"; +import { EtsyTools } from "./tools/tools"; + +export const etsyClientToolkit = createClientToolkit( + baseEtsyToolkitConfig, + { + name: "Etsy Toolkit", + description: "Etsy toolkit for fetching listing details.", + icon: SiEtsy, + form: null, + type: ToolkitGroups.DataSource, + Wrapper: EtsyWrapper, + envVars: [ + { + type: "all", + keys: ["AUTH_ETSY_ID"], + description: ( + + Create a Etsy OAuth application{" "} + here + + ), + }, + ], + }, + { + [EtsyTools.getListings]: getListingsClientConfig, + }, +); diff --git a/src/toolkits/toolkits/Etsy/security-data-storage.ts b/src/toolkits/toolkits/Etsy/security-data-storage.ts new file mode 100644 index 00000000..4638ac4f --- /dev/null +++ b/src/toolkits/toolkits/Etsy/security-data-storage.ts @@ -0,0 +1,37 @@ +import type { ISecurityDataStorage, SecurityDataFilter, Tokens } from "etsy-ts"; +import { api } from "@/trpc/server"; +import { etsyScopes } from "@/server/auth/custom-providers/etsy"; + +export class EtsySecurityDataStorage implements ISecurityDataStorage { + async storeAccessToken(filter: SecurityDataFilter, accessToken: Tokens) { + await api.accounts.updateAccount({ + provider: "etsy", + providerAccountId: filter.etsyUserId.toString(), + data: { + access_token: accessToken.accessToken, + refresh_token: accessToken.refreshToken, + token_type: accessToken.tokenType, + expires_at: accessToken.expiresIn + Date.now() / 1000, + scope: etsyScopes, + }, + }); + } + + async findAccessToken(): Promise { + const account = await api.accounts.getAccountByProvider("etsy"); + if ( + !account?.access_token || + !account.refresh_token || + !account.token_type || + !account.expires_at + ) + return undefined; + + return { + accessToken: account.access_token, + refreshToken: account.refresh_token, + tokenType: account.token_type, + expiresIn: account.expires_at - Date.now() / 1000, + }; + } +} diff --git a/src/toolkits/toolkits/Etsy/server.ts b/src/toolkits/toolkits/Etsy/server.ts new file mode 100644 index 00000000..a2cc94f5 --- /dev/null +++ b/src/toolkits/toolkits/Etsy/server.ts @@ -0,0 +1,38 @@ +import { Etsy } from "etsy-ts"; + +import { createServerToolkit } from "../../create-toolkit"; + +import { api } from "@/trpc/server"; + +import { baseEtsyToolkitConfig } from "./base"; + +import { EtsyTools } from "./tools/tools"; +import { EtsySecurityDataStorage } from "./security-data-storage"; + +import { getListingsServerConfig } from "@/toolkits/toolkits/etsy/tools/get-listings/server"; + +export const etsyToolkitServer = createServerToolkit( + baseEtsyToolkitConfig, + "You have access to the Etsy toolkit for general account management. Currently, this toolkit provides:\n" + + "- **Get Listings**: Retrieves all listings and their image URLs associated with the shop associated with the signed-in user.\n\n", + async () => { + const account = await api.accounts.getAccountByProvider("etsy"); + + if (!account) { + throw new Error("No Etsy account found"); + } + if (!account.access_token) { + throw new Error("No Etsy access token found"); + } + + const etsy = new Etsy({ + apiKey: process.env.AUTH_ETSY_ID!, + securityDataStorage: new EtsySecurityDataStorage(), + enableTokenRefresh: true, + }); + + return { + [EtsyTools.getListings]: getListingsServerConfig(etsy), + }; + }, +); diff --git a/src/toolkits/toolkits/Etsy/tools/get-listings/base.ts b/src/toolkits/toolkits/Etsy/tools/get-listings/base.ts new file mode 100644 index 00000000..74dc6515 --- /dev/null +++ b/src/toolkits/toolkits/Etsy/tools/get-listings/base.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; +import { createBaseTool } from "@/toolkits/create-tool"; +import type { IShopListing } from "etsy-ts"; + +export const getListings = createBaseTool({ + description: + "Fetches all listings from the Etsy shop associated with the authenticated user.", + inputSchema: z.object({}), + outputSchema: z.object({ + results: z.array(z.custom()), + }), +}); diff --git a/src/toolkits/toolkits/Etsy/tools/get-listings/client.tsx b/src/toolkits/toolkits/Etsy/tools/get-listings/client.tsx new file mode 100644 index 00000000..828a8026 --- /dev/null +++ b/src/toolkits/toolkits/Etsy/tools/get-listings/client.tsx @@ -0,0 +1,26 @@ +import { Search } from "lucide-react"; +import type { ClientToolConfig } from "@/toolkits/types"; +import type { getListings } from "./base"; + +export const getListingsClientConfig: ClientToolConfig< + typeof getListings.inputSchema.shape, + typeof getListings.outputSchema.shape +> = { + CallComponent: ({ isPartial }) => ( +

+ + {isPartial && ...} +
+ ), + ResultComponent: ({ result: { results } }) => + results.length > 0 ? ( +
+

Listings

+ {results.map((listing) => ( +
{listing.title}
+ ))} +
+ ) : ( +

No listings found

+ ), +}; diff --git a/src/toolkits/toolkits/Etsy/tools/get-listings/server.ts b/src/toolkits/toolkits/Etsy/tools/get-listings/server.ts new file mode 100644 index 00000000..f6e11259 --- /dev/null +++ b/src/toolkits/toolkits/Etsy/tools/get-listings/server.ts @@ -0,0 +1,45 @@ +import type { Etsy } from "etsy-ts"; + +import type { ServerToolConfig } from "@/toolkits/types"; +import type { getListings } from "./base"; + +export const getListingsServerConfig = ( + etsy: Etsy, +): ServerToolConfig< + typeof getListings.inputSchema.shape, + typeof getListings.outputSchema.shape +> => { + return { + callback: async () => { + try { + const user = await etsy.User.getMe(); + + const userId = user.data.user_id; + + if (!userId) throw new Error("Missing Etsy user ID"); + + const shop = await etsy.Shop.getShopByOwnerUserId(userId); + + const shopId = shop.data.shop_id; + + if (!shopId) throw new Error("Missing Etsy shop ID"); + + const listings = await etsy.ShopListing.getFeaturedListingsByShop({ + shopId, + }); + + if (!listings.data.results) throw new Error("Missing Etsy listings"); + + return { + results: listings.data.results, + }; + } catch (error) { + console.error("Etsy API error:", error); + throw new Error("Failed to fetch listings from Etsy"); + } + }, + message: + "Successfully retrieved the Etsy listing. The user is shown the responses in the UI. Do not reiterate them. " + + "If you called this tool because the user asked a question, answer the question.", + }; +}; diff --git a/src/toolkits/toolkits/Etsy/tools/tools.ts b/src/toolkits/toolkits/Etsy/tools/tools.ts new file mode 100644 index 00000000..8af37ff8 --- /dev/null +++ b/src/toolkits/toolkits/Etsy/tools/tools.ts @@ -0,0 +1,3 @@ +export enum EtsyTools { + getListings = "get-listings", +} diff --git a/src/toolkits/toolkits/Etsy/wrapper.tsx b/src/toolkits/toolkits/Etsy/wrapper.tsx new file mode 100644 index 00000000..238990ad --- /dev/null +++ b/src/toolkits/toolkits/Etsy/wrapper.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState } from "react"; + +import { SiEtsy } from "@icons-pack/react-simple-icons"; + +import { signIn } from "next-auth/react"; + +import { api } from "@/trpc/react"; + +import { + AuthButton, + AuthRequiredDialog, +} from "@/toolkits/lib/auth-required-dialog"; + +import type { ClientToolkitWrapper } from "@/toolkits/types"; +import { Toolkits } from "../shared"; + +export const EtsyWrapper: ClientToolkitWrapper = ({ Item }) => { + const { data: hasAccount, isLoading } = + api.accounts.hasProviderAccount.useQuery("etsy"); + + const [isAuthRequiredDialogOpen, setIsAuthRequiredDialogOpen] = + useState(false); + + if (isLoading) { + return ; + } + + if (!hasAccount) { + return ( + <> + setIsAuthRequiredDialogOpen(true)} + /> + { + void signIn("etsy", { + callbackUrl: `${window.location.href}?${Toolkits.Etsy}=true`, + }); + }} + > + Connect + + } + /> + + ); + } + + return ; +}; diff --git a/src/toolkits/toolkits/client.ts b/src/toolkits/toolkits/client.ts index a62c3140..29087577 100644 --- a/src/toolkits/toolkits/client.ts +++ b/src/toolkits/toolkits/client.ts @@ -16,6 +16,7 @@ import { e2bClientToolkit } from "./e2b/client"; import { discordClientToolkit } from "./discord/client"; import { stravaClientToolkit } from "./strava/client"; import { spotifyClientToolkit } from "./spotify/client"; +import { etsyClientToolkit } from "./etsy/client"; import { videoClientToolkit } from "./video/client"; import { twitterClientToolkit } from "./twitter/client"; @@ -38,6 +39,7 @@ export const clientToolkits: ClientToolkits = { [Toolkits.Discord]: discordClientToolkit, [Toolkits.Strava]: stravaClientToolkit, [Toolkits.Spotify]: spotifyClientToolkit, + [Toolkits.Etsy]: etsyClientToolkit, [Toolkits.Video]: videoClientToolkit, [Toolkits.Twitter]: twitterClientToolkit, }; diff --git a/src/toolkits/toolkits/server.ts b/src/toolkits/toolkits/server.ts index 93095e09..ed03b433 100644 --- a/src/toolkits/toolkits/server.ts +++ b/src/toolkits/toolkits/server.ts @@ -10,6 +10,7 @@ import { e2bToolkitServer } from "./e2b/server"; import { discordToolkitServer } from "./discord/server"; import { stravaToolkitServer } from "./strava/server"; import { spotifyToolkitServer } from "./spotify/server"; +import { etsyToolkitServer } from "./etsy/server"; import { videoToolkitServer } from "./video/server"; import { twitterToolkitServer } from "./twitter/server"; import { @@ -37,6 +38,7 @@ export const serverToolkits: ServerToolkits = { [Toolkits.Discord]: discordToolkitServer, [Toolkits.Strava]: stravaToolkitServer, [Toolkits.Spotify]: spotifyToolkitServer, + [Toolkits.Etsy]: etsyToolkitServer, [Toolkits.Video]: videoToolkitServer, [Toolkits.Twitter]: twitterToolkitServer, }; diff --git a/src/toolkits/toolkits/shared.ts b/src/toolkits/toolkits/shared.ts index ef7e0796..12040b75 100644 --- a/src/toolkits/toolkits/shared.ts +++ b/src/toolkits/toolkits/shared.ts @@ -20,6 +20,8 @@ import type { stravaParameters } from "./strava/base"; import type { StravaTools } from "./strava/tools"; import type { spotifyParameters } from "./spotify/base"; import type { SpotifyTools } from "./spotify/tools"; +import type { etsyParameters } from "./etsy/base"; +import type { EtsyTools } from "./etsy/tools/tools"; import type { VideoTools } from "./video/tools"; import type { videoParameters } from "./video/base"; import type { TwitterTools } from "./twitter/tools"; @@ -37,6 +39,7 @@ export enum Toolkits { Discord = "discord", Strava = "strava", Spotify = "spotify", + Etsy = "etsy", Video = "video", Twitter = "twitter", } @@ -53,6 +56,7 @@ export type ServerToolkitNames = { [Toolkits.Discord]: DiscordTools; [Toolkits.Strava]: StravaTools; [Toolkits.Spotify]: SpotifyTools; + [Toolkits.Etsy]: EtsyTools; [Toolkits.Video]: VideoTools; [Toolkits.Twitter]: TwitterTools; }; @@ -69,6 +73,7 @@ export type ServerToolkitParameters = { [Toolkits.Discord]: typeof discordParameters.shape; [Toolkits.Strava]: typeof stravaParameters.shape; [Toolkits.Spotify]: typeof spotifyParameters.shape; + [Toolkits.Etsy]: typeof etsyParameters.shape; [Toolkits.Video]: typeof videoParameters.shape; [Toolkits.Twitter]: typeof twitterParameters.shape; };