diff --git a/.env.example b/.env.example index 6f6aa6e1..08c2984a 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ # ---------- # Toolkit uses Prisma as an ORM for its Postgres database -DATABASE_URL="postgresql://postgres:password@localhost:5432/toolkit" +DATABASE_URL="postgresql://neondb_owner:npg_7ajtY2GrfnOi@ep-steep-voice-ahz2l2ex-pooler.c-3.us-east-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require" # ---------- # Redis @@ -29,7 +29,7 @@ VERCEL_BLOB_API_URL=http://localhost:6969 # ---------- # This is required to call the LLM -OPENROUTER_API_KEY="" +OPENROUTER_API_KEY="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" # ---------- # AUTHENTICATION @@ -41,7 +41,7 @@ OPENROUTER_API_KEY="" NEXTAUTH_URL="http://localhost:3000" # run `pnpm dlx auth secret` to securely generate and set this value -AUTH_SECRET="" +AUTH_SECRET="XRBSJbmm6jf3Zfk5ebuQwLK8yNIc35cHcN4E+SX4jmo=" # Added by `npx auth`. Read more: https://cli.authjs.dev # Authentication providers @@ -74,6 +74,10 @@ AUTH_SECRET="" # AUTH_SPOTIFY_ID= # AUTH_SPOTIFY_SECRET= +# Slack Provider - https://authjs.dev/getting-started/providers/slack +# AUTH_SLACK_ID= +# AUTH_SLACK_SECRET= + # Strava Provider - https://authjs.dev/getting-started/providers/strava # AUTH_STRAVA_ID= # AUTH_STRAVA_SECRET= diff --git a/package.json b/package.json index 5616cf0b..3e2263c7 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-use-controllable-state": "^1.2.2", + "@slack/web-api": "^7.13.0", "@spotify/web-api-ts-sdk": "^1.2.0", "@t3-oss/env-nextjs": "^0.12.0", "@tanstack/react-query": "^5.69.0", @@ -82,8 +83,8 @@ "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", + "etsy-ts": "^4.2.0", "exa-js": "^1.8.8", "fast-deep-equal": "^3.1.3", "googleapis": "^150.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cafcd36..e7184280 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: '@radix-ui/react-use-controllable-state': specifier: ^1.2.2 version: 1.2.2(@types/react@19.1.6)(react@19.1.0) + '@slack/web-api': + specifier: ^7.13.0 + version: 7.13.0 '@spotify/web-api-ts-sdk': specifier: ^1.2.0 version: 1.2.0 @@ -179,12 +182,12 @@ 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 + etsy-ts: + specifier: ^4.2.0 + version: 4.2.0 exa-js: specifier: ^1.8.8 version: 1.8.8(encoding@0.1.13)(ws@8.18.3)(zod@3.25.56) @@ -1798,6 +1801,18 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@slack/logger@4.0.0': + resolution: {integrity: sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/types@2.19.0': + resolution: {integrity: sha512-7+QZ38HGcNh/b/7MpvPG6jnw7mliV6UmrquJLqgdxkzJgQEYUcEztvFWRU49z0x4vthF0ixL5lTK601AXrS8IA==} + engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + + '@slack/web-api@7.13.0': + resolution: {integrity: sha512-ERcExbWrnkDN8ovoWWe6Wgt/usanj1dWUd18dJLpctUI4mlPS0nKt81Joh8VI+OPbNnY1lIilVt9gdMBD9U2ig==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + '@spotify/web-api-ts-sdk@1.2.0': resolution: {integrity: sha512-JUaebva3Ohwo5I5tuTqyW/FKGOMbb40YevJMySAOINRxP7qQ/AMjBzfJx0zeO6yS+wAPfQSoGNsZaUggHw8vsA==} @@ -2503,6 +2518,9 @@ packages: peerDependencies: axios: '>= 0.18 < 0.19.0 || >= 0.19.1' + axios@1.13.3: + resolution: {integrity: sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==} + axios@1.7.7: resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} @@ -3204,6 +3222,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.0.2: resolution: {integrity: sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==} engines: {node: '>=18.0.0'} @@ -3711,6 +3732,9 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-electron@2.2.2: + resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -5346,6 +5370,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} @@ -7112,6 +7137,29 @@ snapshots: '@sinclair/typebox@0.27.8': {} + '@slack/logger@4.0.0': + dependencies: + '@types/node': 20.19.9 + + '@slack/types@2.19.0': {} + + '@slack/web-api@7.13.0': + dependencies: + '@slack/logger': 4.0.0 + '@slack/types': 2.19.0 + '@types/node': 20.19.9 + '@types/retry': 0.12.0 + axios: 1.13.3 + eventemitter3: 5.0.4 + form-data: 4.0.4 + is-electron: 2.2.2 + is-stream: 2.0.1 + p-queue: 6.6.2 + p-retry: 4.6.2 + retry: 0.13.1 + transitivePeerDependencies: + - debug + '@spotify/web-api-ts-sdk@1.2.0': {} '@standard-schema/utils@0.3.0': {} @@ -7809,6 +7857,14 @@ snapshots: dependencies: axios: 1.7.7 + axios@1.13.3: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axios@1.7.7: dependencies: follow-redirects: 1.15.9 @@ -8683,6 +8739,8 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} + eventsource-parser@3.0.2: {} eventsource@3.0.7: @@ -9372,6 +9430,8 @@ snapshots: is-decimal@2.0.1: {} + is-electron@2.2.2: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 00000000..d5459381 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +import "dotenv/config"; +import type { PrismaConfig } from "prisma"; +import { env } from "prisma/config"; + +export default { + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + seed: "tsx prisma/seed.ts", + }, + datasource: { + url: env("DATABASE_URL"), + }, +} satisfies PrismaConfig; diff --git a/src/toolkits/toolkits/client.ts b/src/toolkits/toolkits/client.ts index 29087577..366eaa21 100644 --- a/src/toolkits/toolkits/client.ts +++ b/src/toolkits/toolkits/client.ts @@ -19,6 +19,7 @@ import { spotifyClientToolkit } from "./spotify/client"; import { etsyClientToolkit } from "./etsy/client"; import { videoClientToolkit } from "./video/client"; import { twitterClientToolkit } from "./twitter/client"; +import { slackClientToolkit } from "./slack/client"; export type ClientToolkits = { [K in Toolkits]: ClientToolkit< @@ -42,6 +43,7 @@ export const clientToolkits: ClientToolkits = { [Toolkits.Etsy]: etsyClientToolkit, [Toolkits.Video]: videoClientToolkit, [Toolkits.Twitter]: twitterClientToolkit, + [Toolkits.Slack]: slackClientToolkit, }; export function getClientToolkit( diff --git a/src/toolkits/toolkits/server.ts b/src/toolkits/toolkits/server.ts index ed03b433..9c281f9b 100644 --- a/src/toolkits/toolkits/server.ts +++ b/src/toolkits/toolkits/server.ts @@ -13,6 +13,7 @@ import { spotifyToolkitServer } from "./spotify/server"; import { etsyToolkitServer } from "./etsy/server"; import { videoToolkitServer } from "./video/server"; import { twitterToolkitServer } from "./twitter/server"; +import { slackToolkitServer } from "./slack/server"; import { Toolkits, type ServerToolkitNames, @@ -41,6 +42,7 @@ export const serverToolkits: ServerToolkits = { [Toolkits.Etsy]: etsyToolkitServer, [Toolkits.Video]: videoToolkitServer, [Toolkits.Twitter]: twitterToolkitServer, + [Toolkits.Slack]: slackToolkitServer, }; export function getServerToolkit( diff --git a/src/toolkits/toolkits/shared.ts b/src/toolkits/toolkits/shared.ts index 12040b75..010e8e11 100644 --- a/src/toolkits/toolkits/shared.ts +++ b/src/toolkits/toolkits/shared.ts @@ -26,6 +26,8 @@ import type { VideoTools } from "./video/tools"; import type { videoParameters } from "./video/base"; import type { TwitterTools } from "./twitter/tools"; import type { twitterParameters } from "./twitter/base"; +import type { SlackTools } from "./slack/tools"; +import type { slackParameters } from "./slack/base"; export enum Toolkits { Exa = "exa", @@ -42,6 +44,7 @@ export enum Toolkits { Etsy = "etsy", Video = "video", Twitter = "twitter", + Slack = "slack", } export type ServerToolkitNames = { @@ -59,6 +62,7 @@ export type ServerToolkitNames = { [Toolkits.Etsy]: EtsyTools; [Toolkits.Video]: VideoTools; [Toolkits.Twitter]: TwitterTools; + [Toolkits.Slack]: SlackTools; }; export type ServerToolkitParameters = { @@ -76,4 +80,5 @@ export type ServerToolkitParameters = { [Toolkits.Etsy]: typeof etsyParameters.shape; [Toolkits.Video]: typeof videoParameters.shape; [Toolkits.Twitter]: typeof twitterParameters.shape; + [Toolkits.Slack]: typeof slackParameters.shape; }; diff --git a/src/toolkits/toolkits/slack/base.ts b/src/toolkits/toolkits/slack/base.ts new file mode 100644 index 00000000..95bc521c --- /dev/null +++ b/src/toolkits/toolkits/slack/base.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +import { + SlackTools, + listChannelsTool, + sendMessageTool, + searchMessagesTool, + getUserInfoTool, + listWorkspacesTool, +} from "./tools"; + +import type { ToolkitConfig } from "@/toolkits/types"; + +export const slackParameters = z.object({ + // No parameters needed for OAuth-based tools +}); + +export const baseSlackToolkitConfig: ToolkitConfig< + SlackTools, + typeof slackParameters.shape +> = { + tools: { + [SlackTools.ListChannels]: listChannelsTool, + [SlackTools.SendMessage]: sendMessageTool, + [SlackTools.SearchMessages]: searchMessagesTool, + [SlackTools.GetUserInfo]: getUserInfoTool, + [SlackTools.ListWorkspaces]: listWorkspacesTool, + }, + parameters: slackParameters, +}; diff --git a/src/toolkits/toolkits/slack/client.tsx b/src/toolkits/toolkits/slack/client.tsx new file mode 100644 index 00000000..538b4a44 --- /dev/null +++ b/src/toolkits/toolkits/slack/client.tsx @@ -0,0 +1,51 @@ +import { SiSlack } from "@icons-pack/react-simple-icons"; + +import { createClientToolkit } from "@/toolkits/create-toolkit"; + +import { baseSlackToolkitConfig } from "./base"; +import { SlackTools } from "./tools"; +import { + listChannelsToolConfigClient, + sendMessageToolConfigClient, + searchMessagesToolConfigClient, + getUserInfoToolConfigClient, + listWorkspacesToolConfigClient, +} from "./tools/client"; + +import { Link } from "../components/link"; + +import { ToolkitGroups } from "@/toolkits/types"; + +import { SlackWrapper } from "./wrapper"; + +export const slackClientToolkit = createClientToolkit( + baseSlackToolkitConfig, + { + name: "Slack", + description: + "Interact with your Slack workspace - send messages, search, and manage channels", + icon: SiSlack, + form: null, + Wrapper: SlackWrapper, + type: ToolkitGroups.DataSource, + envVars: [ + { + type: "all", + keys: ["AUTH_SLACK_ID", "AUTH_SLACK_SECRET"], + description: ( + + Get an Auth Client ID and Secret from{" "} + here + + ), + }, + ], + }, + { + [SlackTools.ListChannels]: listChannelsToolConfigClient, + [SlackTools.SendMessage]: sendMessageToolConfigClient, + [SlackTools.SearchMessages]: searchMessagesToolConfigClient, + [SlackTools.GetUserInfo]: getUserInfoToolConfigClient, + [SlackTools.ListWorkspaces]: listWorkspacesToolConfigClient, + }, +); diff --git a/src/toolkits/toolkits/slack/server.ts b/src/toolkits/toolkits/slack/server.ts new file mode 100644 index 00000000..ca0d2723 --- /dev/null +++ b/src/toolkits/toolkits/slack/server.ts @@ -0,0 +1,62 @@ +import { WebClient } from "@slack/web-api"; + +import { createServerToolkit } from "@/toolkits/create-toolkit"; + +import { baseSlackToolkitConfig } from "./base"; +import { SlackTools } from "./tools"; +import { + listChannelsToolConfigServer, + sendMessageToolConfigServer, + searchMessagesToolConfigServer, + getUserInfoToolConfigServer, + listWorkspacesToolConfigServer, +} from "./tools/server"; + +import { api } from "@/trpc/server"; + +export const slackToolkitServer = createServerToolkit( + baseSlackToolkitConfig, + `You have access to the Slack toolkit for interacting with your Slack workspace. This toolkit provides: + +- **List Channels**: Get a list of all channels in your workspace (public, private, DMs) +- **Send Message**: Send messages to channels or threads +- **Search Messages**: Search for messages across your workspace +- **Get User Info**: Retrieve information about your Slack account +- **List Workspaces**: Get information about your current workspace/team + +**Tool Sequencing Strategies:** +1. **Channel Discovery**: Start with List Channels to see available channels +2. **Message Operations**: Use Send Message to post to channels, optionally using thread_ts for replies +3. **Information Retrieval**: Use Search Messages to find specific content +4. **User Context**: Use Get User Info to understand your account capabilities +5. **Workspace Info**: Use List Workspaces to get team details + +**Best Practices:** +- Always get channel IDs using List Channels before sending messages +- Use search to find relevant context before posting +- Respect channel privacy settings +- Use threaded replies (thread_ts) to keep conversations organized`, + async () => { + const account = await api.accounts.getAccountByProvider("slack"); + + if (!account) { + throw new Error( + "No Slack account found. Please connect your Slack account first.", + ); + } + + if (!account.access_token) { + throw new Error("No access token available for Slack account."); + } + + const client = new WebClient(account.access_token); + + return { + [SlackTools.ListChannels]: listChannelsToolConfigServer(client), + [SlackTools.SendMessage]: sendMessageToolConfigServer(client), + [SlackTools.SearchMessages]: searchMessagesToolConfigServer(client), + [SlackTools.GetUserInfo]: getUserInfoToolConfigServer(client), + [SlackTools.ListWorkspaces]: listWorkspacesToolConfigServer(client), + }; + }, +); diff --git a/src/toolkits/toolkits/slack/tools/client.ts b/src/toolkits/toolkits/slack/tools/client.ts new file mode 100644 index 00000000..45329caa --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/client.ts @@ -0,0 +1,5 @@ +export * from "./list-channels/client"; +export * from "./send-message/client"; +export * from "./search-messages/client"; +export * from "./get-user-info/client"; +export * from "./list-workspaces/client"; diff --git a/src/toolkits/toolkits/slack/tools/get-user-info/base.ts b/src/toolkits/toolkits/slack/tools/get-user-info/base.ts new file mode 100644 index 00000000..acb3bb71 --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/get-user-info/base.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { createBaseTool } from "@/toolkits/create-tool"; + +export const getUserInfoTool = createBaseTool({ + description: "Get information about the authenticated Slack user", + inputSchema: z.object({ + // No input needed - uses authenticated user + }), + outputSchema: z.object({ + user: z.object({ + id: z.string(), + name: z.string(), + real_name: z.string().optional(), + email: z.string().optional(), + is_admin: z.boolean().optional(), + is_owner: z.boolean().optional(), + is_bot: z.boolean().optional(), + profile: z + .object({ + display_name: z.string().optional(), + status_text: z.string().optional(), + status_emoji: z.string().optional(), + image_192: z.string().optional(), + }) + .optional(), + }), + }), +}); diff --git a/src/toolkits/toolkits/slack/tools/get-user-info/client.tsx b/src/toolkits/toolkits/slack/tools/get-user-info/client.tsx new file mode 100644 index 00000000..9bcc761f --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/get-user-info/client.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { createClientTool } from "@/toolkits/create-tool"; +import { getUserInfoTool } from "./base"; + +export const getUserInfoToolConfigClient = createClientTool(getUserInfoTool, { + CallComponent: () => ( +

Get Slack User Info

+ ), + ResultComponent: ({ result: { user } }) => ( +
+

Your Slack Profile

+
+
+ Name: {user.real_name || user.name} +
+ {user.email && ( +
+ Email: {user.email} +
+ )} + {user.profile?.display_name && ( +
+ Display Name:{" "} + {user.profile.display_name} +
+ )} + {user.profile?.status_text && ( +
+ Status:{" "} + {user.profile.status_emoji} {user.profile.status_text} +
+ )} +
+ {user.is_admin && "Admin • "} + {user.is_owner && "Owner • "} + ID: {user.id} +
+
+
+ ), +}); diff --git a/src/toolkits/toolkits/slack/tools/get-user-info/server.ts b/src/toolkits/toolkits/slack/tools/get-user-info/server.ts new file mode 100644 index 00000000..c5ae1392 --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/get-user-info/server.ts @@ -0,0 +1,43 @@ +import { WebClient } from "@slack/web-api"; +import type { getUserInfoTool } from "./base"; +import type { ServerToolConfig } from "@/toolkits/types"; + +export const getUserInfoToolConfigServer = ( + client: WebClient, +): ServerToolConfig< + typeof getUserInfoTool.inputSchema.shape, + typeof getUserInfoTool.outputSchema.shape +> => { + return { + callback: async () => { + const authResult = await client.auth.test(); + const userId = authResult.user_id!; + + const userResult = await client.users.info({ + user: userId, + }); + + const user = userResult.user!; + + return { + user: { + id: user.id!, + name: user.name!, + real_name: user.real_name, + email: user.profile?.email, + is_admin: user.is_admin, + is_owner: user.is_owner, + is_bot: user.is_bot, + profile: user.profile + ? { + display_name: user.profile.display_name, + status_text: user.profile.status_text, + status_emoji: user.profile.status_emoji, + image_192: user.profile.image_192, + } + : undefined, + }, + }; + }, + }; +}; diff --git a/src/toolkits/toolkits/slack/tools/index.ts b/src/toolkits/toolkits/slack/tools/index.ts new file mode 100644 index 00000000..cd7d7a49 --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/index.ts @@ -0,0 +1,13 @@ +export enum SlackTools { + ListChannels = "list-channels", + SendMessage = "send-message", + SearchMessages = "search-messages", + GetUserInfo = "get-user-info", + ListWorkspaces = "list-workspaces", +} + +export * from "./list-channels/base"; +export * from "./send-message/base"; +export * from "./search-messages/base"; +export * from "./get-user-info/base"; +export * from "./list-workspaces/base"; diff --git a/src/toolkits/toolkits/slack/tools/list-channels/base.ts b/src/toolkits/toolkits/slack/tools/list-channels/base.ts new file mode 100644 index 00000000..49672370 --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/list-channels/base.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { createBaseTool } from "@/toolkits/create-tool"; + +export const listChannelsTool = createBaseTool({ + description: "List all channels in your Slack workspace", + inputSchema: z.object({ + types: z + .string() + .optional() + .describe( + "Comma-separated list of channel types (public_channel, private_channel, mpim, im). Defaults to public_channel", + ), + limit: z + .number() + .optional() + .describe("Maximum number of channels to return. Defaults to 100"), + }), + outputSchema: z.object({ + channels: z.array( + z.object({ + id: z.string(), + name: z.string(), + is_channel: z.boolean().optional(), + is_private: z.boolean().optional(), + is_member: z.boolean().optional(), + num_members: z.number().optional(), + topic: z + .object({ + value: z.string(), + }) + .optional(), + purpose: z + .object({ + value: z.string(), + }) + .optional(), + }), + ), + }), +}); diff --git a/src/toolkits/toolkits/slack/tools/list-channels/client.tsx b/src/toolkits/toolkits/slack/tools/list-channels/client.tsx new file mode 100644 index 00000000..aed33555 --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/list-channels/client.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { createClientTool } from "@/toolkits/create-tool"; +import { listChannelsTool } from "./base"; + +export const listChannelsToolConfigClient = createClientTool( + listChannelsTool, + { + CallComponent: () => ( +

List Slack Channels

+ ), + ResultComponent: ({ result: { channels } }) => ( +
+

Your Slack Channels

+ {channels.length === 0 ? ( +
+ No channels found in your workspace. +
+ ) : ( +
+
+ Found {channels.length} channels: +
+
+ {channels.map((channel) => ( +
+
+ + {channel.is_private ? "🔒" : "#"} {channel.name} + +
+
+ {channel.num_members} members +
+
+ ))} +
+
+ )} +
+ ), + }, +); diff --git a/src/toolkits/toolkits/slack/tools/list-channels/server.ts b/src/toolkits/toolkits/slack/tools/list-channels/server.ts new file mode 100644 index 00000000..8ce8f49f --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/list-channels/server.ts @@ -0,0 +1,37 @@ +import { WebClient } from "@slack/web-api"; +import type { listChannelsTool } from "./base"; +import type { ServerToolConfig } from "@/toolkits/types"; + +export const listChannelsToolConfigServer = ( + client: WebClient, +): ServerToolConfig< + typeof listChannelsTool.inputSchema.shape, + typeof listChannelsTool.outputSchema.shape +> => { + return { + callback: async ({ types, limit }) => { + const result = await client.conversations.list({ + types: types || "public_channel", + limit: limit || 100, + }); + + return { + channels: + result.channels?.map((channel) => ({ + id: channel.id!, + name: channel.name!, + is_channel: channel.is_channel, + is_private: channel.is_private, + is_member: channel.is_member, + num_members: channel.num_members, + topic: channel.topic + ? { value: channel.topic.value || "" } + : undefined, + purpose: channel.purpose + ? { value: channel.purpose.value || "" } + : undefined, + })) || [], + }; + }, + }; +}; diff --git a/src/toolkits/toolkits/slack/tools/list-workspaces/base.ts b/src/toolkits/toolkits/slack/tools/list-workspaces/base.ts new file mode 100644 index 00000000..52bc78b9 --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/list-workspaces/base.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import { createBaseTool } from "@/toolkits/create-tool"; + +export const listWorkspacesTool = createBaseTool({ + description: "List all Slack workspaces (teams) the user has access to", + inputSchema: z.object({ + // No input needed - uses authenticated user's workspaces + }), + outputSchema: z.object({ + team: z.object({ + id: z.string(), + name: z.string(), + domain: z.string().optional(), + email_domain: z.string().optional(), + icon: z + .object({ + image_default: z.boolean().optional(), + image_68: z.string().optional(), + image_88: z.string().optional(), + image_132: z.string().optional(), + }) + .optional(), + }), + }), +}); diff --git a/src/toolkits/toolkits/slack/tools/list-workspaces/client.tsx b/src/toolkits/toolkits/slack/tools/list-workspaces/client.tsx new file mode 100644 index 00000000..46eae53d --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/list-workspaces/client.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { createClientTool } from "@/toolkits/create-tool"; +import { listWorkspacesTool } from "./base"; + +export const listWorkspacesToolConfigClient = createClientTool( + listWorkspacesTool, + { + CallComponent: () => ( +

Get Workspace Info

+ ), + ResultComponent: ({ result: { team } }) => ( +
+

Workspace Information

+
+
+ Name: {team.name} +
+ {team.domain && ( +
+ Domain: {team.domain}.slack.com +
+ )} + {team.email_domain && ( +
+ Email Domain:{" "} + {team.email_domain} +
+ )} +
ID: {team.id}
+
+
+ ), + }, +); diff --git a/src/toolkits/toolkits/slack/tools/list-workspaces/server.ts b/src/toolkits/toolkits/slack/tools/list-workspaces/server.ts new file mode 100644 index 00000000..ec553307 --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/list-workspaces/server.ts @@ -0,0 +1,26 @@ +import { WebClient } from "@slack/web-api"; +import type { listWorkspacesTool } from "./base"; +import type { ServerToolConfig } from "@/toolkits/types"; + +export const listWorkspacesToolConfigServer = ( + client: WebClient, +): ServerToolConfig< + typeof listWorkspacesTool.inputSchema.shape, + typeof listWorkspacesTool.outputSchema.shape +> => { + return { + callback: async () => { + const result = await client.team.info(); + + return { + team: { + id: result.team!.id!, + name: result.team!.name!, + domain: result.team!.domain, + email_domain: result.team!.email_domain, + icon: result.team!.icon, + }, + }; + }, + }; +}; diff --git a/src/toolkits/toolkits/slack/tools/search-messages/base.ts b/src/toolkits/toolkits/slack/tools/search-messages/base.ts new file mode 100644 index 00000000..3877342f --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/search-messages/base.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { createBaseTool } from "@/toolkits/create-tool"; + +export const searchMessagesTool = createBaseTool({ + description: "Search for messages in your Slack workspace", + inputSchema: z.object({ + query: z.string().describe("Search query string"), + count: z + .number() + .optional() + .describe("Number of results to return. Defaults to 20"), + sort: z + .enum(["score", "timestamp"]) + .optional() + .describe("Sort results by relevance (score) or time (timestamp)"), + }), + outputSchema: z.object({ + messages: z.array( + z.object({ + type: z.string(), + text: z.string(), + user: z.string().optional(), + username: z.string().optional(), + ts: z.string(), + channel: z + .object({ + id: z.string(), + name: z.string(), + }) + .optional(), + permalink: z.string().optional(), + }), + ), + total: z.number(), + }), +}); diff --git a/src/toolkits/toolkits/slack/tools/search-messages/client.tsx b/src/toolkits/toolkits/slack/tools/search-messages/client.tsx new file mode 100644 index 00000000..15858b53 --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/search-messages/client.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { createClientTool } from "@/toolkits/create-tool"; +import { searchMessagesTool } from "./base"; + +export const searchMessagesToolConfigClient = createClientTool( + searchMessagesTool, + { + CallComponent: () => ( +

Search Slack Messages

+ ), + ResultComponent: ({ result: { messages, total } }) => ( +
+

Search Results

+ {messages.length === 0 ? ( +
No messages found.
+ ) : ( +
+
+ Found {total} total results (showing {messages.length}): +
+
+ {messages.map((message, idx) => ( +
+
+ + {message.username || message.user} + + {message.channel && ( + + {" "} + in #{message.channel.name} + + )} +
+
{message.text}
+ {message.permalink && ( + + View message + + )} +
+ ))} +
+
+ )} +
+ ), + }, +); diff --git a/src/toolkits/toolkits/slack/tools/search-messages/server.ts b/src/toolkits/toolkits/slack/tools/search-messages/server.ts new file mode 100644 index 00000000..d7282092 --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/search-messages/server.ts @@ -0,0 +1,39 @@ +import { WebClient } from "@slack/web-api"; +import type { searchMessagesTool } from "./base"; +import type { ServerToolConfig } from "@/toolkits/types"; + +export const searchMessagesToolConfigServer = ( + client: WebClient, +): ServerToolConfig< + typeof searchMessagesTool.inputSchema.shape, + typeof searchMessagesTool.outputSchema.shape +> => { + return { + callback: async ({ query, count, sort }) => { + const result = await client.search.messages({ + query, + count: count || 20, + sort: sort || "score", + }); + + return { + messages: + result.messages?.matches?.map((match) => ({ + type: match.type!, + text: match.text!, + user: match.user, + username: match.username, + ts: match.ts!, + channel: match.channel + ? { + id: match.channel.id!, + name: match.channel.name!, + } + : undefined, + permalink: match.permalink, + })) || [], + total: result.messages?.total || 0, + }; + }, + }; +}; diff --git a/src/toolkits/toolkits/slack/tools/send-message/base.ts b/src/toolkits/toolkits/slack/tools/send-message/base.ts new file mode 100644 index 00000000..605edfb3 --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/send-message/base.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { createBaseTool } from "@/toolkits/create-tool"; + +export const sendMessageTool = createBaseTool({ + description: "Send a message to a Slack channel", + inputSchema: z.object({ + channel: z.string().describe("Channel ID to send the message to"), + text: z.string().describe("Message text to send"), + thread_ts: z + .string() + .optional() + .describe("Thread timestamp to reply to (for threaded messages)"), + }), + outputSchema: z.object({ + ok: z.boolean(), + channel: z.string(), + ts: z.string().describe("Timestamp of the sent message"), + message: z.object({ + text: z.string(), + user: z.string().optional(), + }), + }), +}); diff --git a/src/toolkits/toolkits/slack/tools/send-message/client.tsx b/src/toolkits/toolkits/slack/tools/send-message/client.tsx new file mode 100644 index 00000000..48bb5a17 --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/send-message/client.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { createClientTool } from "@/toolkits/create-tool"; +import { sendMessageTool } from "./base"; + +export const sendMessageToolConfigClient = createClientTool(sendMessageTool, { + CallComponent: () => ( +

Send Slack Message

+ ), + ResultComponent: ({ result }) => ( +
+

Message Sent

+
+
+ Channel: {result.channel} +
+
+ Message: {result.message.text} +
+
+ Timestamp: {result.ts} +
+
+
+ ), +}); diff --git a/src/toolkits/toolkits/slack/tools/send-message/server.ts b/src/toolkits/toolkits/slack/tools/send-message/server.ts new file mode 100644 index 00000000..bf94dab6 --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/send-message/server.ts @@ -0,0 +1,30 @@ +import { WebClient } from "@slack/web-api"; +import type { sendMessageTool } from "./base"; +import type { ServerToolConfig } from "@/toolkits/types"; + +export const sendMessageToolConfigServer = ( + client: WebClient, +): ServerToolConfig< + typeof sendMessageTool.inputSchema.shape, + typeof sendMessageTool.outputSchema.shape +> => { + return { + callback: async ({ channel, text, thread_ts }) => { + const result = await client.chat.postMessage({ + channel, + text, + thread_ts, + }); + + return { + ok: result.ok, + channel: result.channel!, + ts: result.ts!, + message: { + text: result.message?.text || text, + user: result.message?.user, + }, + }; + }, + }; +}; diff --git a/src/toolkits/toolkits/slack/tools/server.ts b/src/toolkits/toolkits/slack/tools/server.ts new file mode 100644 index 00000000..3f00439b --- /dev/null +++ b/src/toolkits/toolkits/slack/tools/server.ts @@ -0,0 +1,5 @@ +export * from "./list-channels/server"; +export * from "./send-message/server"; +export * from "./search-messages/server"; +export * from "./get-user-info/server"; +export * from "./list-workspaces/server"; diff --git a/src/toolkits/toolkits/slack/wrapper.tsx b/src/toolkits/toolkits/slack/wrapper.tsx new file mode 100644 index 00000000..60ef9f89 --- /dev/null +++ b/src/toolkits/toolkits/slack/wrapper.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState } from "react"; + +import { SiSlack } 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"; + +const scopes = [ + "channels:read", + "channels:write", + "chat:write", + "search:read", + "users:read", + "team:read", +]; + +export const SlackWrapper: ClientToolkitWrapper = ({ Item }) => { + const { data: account, isLoading } = + api.accounts.getAccountByProvider.useQuery("slack"); + + const [isAuthRequiredDialogOpen, setIsAuthRequiredDialogOpen] = + useState(false); + + if (isLoading) { + return ; + } + + if (!scopes.every((scope) => account?.scope?.includes(scope))) { + return ( + <> + setIsAuthRequiredDialogOpen(true)} + /> + { + void signIn( + "slack", + { + callbackUrl: `${window.location.href}?${Toolkits.Slack}=true`, + }, + { + scope: scopes.join(" "), + }, + ); + }} + > + Connect + + } + /> + + ); + } + + return ; +}; diff --git a/src/toolkits/toolkits/twitter/tools/add-bookmark/base.ts b/src/toolkits/toolkits/twitter/tools/add-bookmark/base.ts new file mode 100644 index 00000000..921e261f --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/add-bookmark/base.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { createBaseTool } from "@/toolkits/create-tool"; + +export const addBookmarkTool = createBaseTool({ + description: + "Bookmark a tweet for later reference. Rate limit: 50 requests per 15 minutes. Requires bookmark.write scope.", + inputSchema: z.object({ + tweet_id: z.string().describe("ID of the tweet to bookmark"), + }), + outputSchema: z.object({ + bookmarked: z.boolean(), + tweet_id: z.string(), + rate_limit: z + .object({ + limit: z.number(), + remaining: z.number(), + reset: z.number(), + }) + .optional(), + }), +}); diff --git a/src/toolkits/toolkits/twitter/tools/add-bookmark/client.tsx b/src/toolkits/toolkits/twitter/tools/add-bookmark/client.tsx new file mode 100644 index 00000000..44439919 --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/add-bookmark/client.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { createClientTool } from "@/toolkits/create-tool"; +import { addBookmarkTool } from "./base"; + +export const addBookmarkToolConfigClient = createClientTool(addBookmarkTool, { + CallComponent: () =>

Add Bookmark

, + ResultComponent: ({ result: { bookmarked, tweet_id, rate_limit } }) => ( +
+

Bookmark Added

+ + {rate_limit && rate_limit.remaining < 10 && ( +
+
⚠️ Rate Limit Warning
+
+ {rate_limit.remaining} of {rate_limit.limit} requests remaining. +
+
+ )} + +
+ {bookmarked ? ( +
+
✓ Success
+
+ Tweet {tweet_id} has been bookmarked. +
+
+ ) : ( +
+
✗ Failed
+
+ Could not bookmark tweet {tweet_id}. +
+
+ )} +
+
+ ), +}); diff --git a/src/toolkits/toolkits/twitter/tools/add-bookmark/server.ts b/src/toolkits/toolkits/twitter/tools/add-bookmark/server.ts new file mode 100644 index 00000000..68cc6a9b --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/add-bookmark/server.ts @@ -0,0 +1,31 @@ +import type { TwitterApi } from "twitter-api-v2"; +import type { addBookmarkTool } from "./base"; +import type { ServerToolConfig } from "@/toolkits/types"; + +export const addBookmarkToolConfigServer = ( + client: TwitterApi, +): ServerToolConfig< + typeof addBookmarkTool.inputSchema.shape, + typeof addBookmarkTool.outputSchema.shape +> => { + return { + callback: async ({ tweet_id }) => { + const response = await client.v2.bookmark(tweet_id); + + // Rate limit info may not be available on all responses + const rateLimit = (response as any).rateLimit + ? { + limit: (response as any).rateLimit.limit, + remaining: (response as any).rateLimit.remaining, + reset: (response as any).rateLimit.reset, + } + : undefined; + + return { + bookmarked: response.data.bookmarked, + tweet_id, + rate_limit: rateLimit, + }; + }, + }; +}; diff --git a/src/toolkits/toolkits/twitter/tools/client.ts b/src/toolkits/toolkits/twitter/tools/client.ts index a3188692..7b28df83 100644 --- a/src/toolkits/toolkits/twitter/tools/client.ts +++ b/src/toolkits/toolkits/twitter/tools/client.ts @@ -1,2 +1,7 @@ export * from "./profile/client"; export * from "./tweets/client"; +export * from "./search-tweets/client"; +export * from "./get-bookmarks/client"; +export * from "./add-bookmark/client"; +export * from "./remove-bookmark/client"; +export * from "./get-tweet-by-id/client"; diff --git a/src/toolkits/toolkits/twitter/tools/get-bookmarks/base.ts b/src/toolkits/toolkits/twitter/tools/get-bookmarks/base.ts new file mode 100644 index 00000000..d5a3ef26 --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/get-bookmarks/base.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { createBaseTool } from "@/toolkits/create-tool"; + +export const getBookmarksTool = createBaseTool({ + description: + "Retrieve your bookmarked tweets. Rate limit: 180 requests per 15 minutes. Requires bookmark.read scope.", + inputSchema: z.object({ + max_results: z + .number() + .min(1) + .max(100) + .default(10) + .describe("Number of bookmarks to retrieve (1-100)"), + }), + outputSchema: z.object({ + bookmarks: z.array( + z.object({ + id: z.string(), + text: z.string(), + author_id: z.string(), + created_at: z.string(), + public_metrics: z + .object({ + retweet_count: z.number(), + reply_count: z.number(), + like_count: z.number(), + quote_count: z.number(), + }) + .optional(), + }), + ), + meta: z.object({ + result_count: z.number(), + next_token: z.string().optional(), + }), + rate_limit: z + .object({ + limit: z.number(), + remaining: z.number(), + reset: z.number(), + }) + .optional(), + }), +}); diff --git a/src/toolkits/toolkits/twitter/tools/get-bookmarks/client.tsx b/src/toolkits/toolkits/twitter/tools/get-bookmarks/client.tsx new file mode 100644 index 00000000..58281378 --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/get-bookmarks/client.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { createClientTool } from "@/toolkits/create-tool"; +import { getBookmarksTool } from "./base"; + +export const getBookmarksToolConfigClient = createClientTool( + getBookmarksTool, + { + CallComponent: () => ( +

Get Bookmarks

+ ), + ResultComponent: ({ result: { bookmarks, meta, rate_limit } }) => ( +
+

Your Bookmarks

+ + {rate_limit && rate_limit.remaining < 30 && ( +
+
+ ⚠️ Rate Limit Warning +
+
+ {rate_limit.remaining} of {rate_limit.limit} requests remaining. +
+
+ )} + + {bookmarks.length === 0 ? ( +
+ You haven't bookmarked any tweets yet. +
+ ) : ( +
+
+ {meta.result_count} bookmarks (showing {bookmarks.length}): +
+
+ {bookmarks.map((tweet) => ( +
+
+ {tweet.text} +
+
+
+ {new Date(tweet.created_at).toLocaleDateString()} +
+ {tweet.public_metrics && ( +
+ ❤️ {tweet.public_metrics.like_count} + 🔄 {tweet.public_metrics.retweet_count} +
+ )} +
+
+ ))} +
+
+ )} +
+ ), + }, +); diff --git a/src/toolkits/toolkits/twitter/tools/get-bookmarks/server.ts b/src/toolkits/toolkits/twitter/tools/get-bookmarks/server.ts new file mode 100644 index 00000000..c05a6113 --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/get-bookmarks/server.ts @@ -0,0 +1,42 @@ +import type { TwitterApi } from "twitter-api-v2"; +import type { getBookmarksTool } from "./base"; +import type { ServerToolConfig } from "@/toolkits/types"; + +export const getBookmarksToolConfigServer = ( + client: TwitterApi, +): ServerToolConfig< + typeof getBookmarksTool.inputSchema.shape, + typeof getBookmarksTool.outputSchema.shape +> => { + return { + callback: async ({ max_results }) => { + const response = await client.v2.bookmarks({ + max_results: max_results || 10, + "tweet.fields": ["created_at", "public_metrics", "author_id"], + }); + + const rateLimit = response.rateLimit + ? { + limit: response.rateLimit.limit, + remaining: response.rateLimit.remaining, + reset: response.rateLimit.reset, + } + : undefined; + + return { + bookmarks: response.data.data.map((tweet) => ({ + id: tweet.id, + text: tweet.text, + author_id: tweet.author_id!, + created_at: tweet.created_at!, + public_metrics: tweet.public_metrics, + })), + meta: { + result_count: response.data.meta.result_count, + next_token: response.data.meta.next_token, + }, + rate_limit: rateLimit, + }; + }, + }; +}; diff --git a/src/toolkits/toolkits/twitter/tools/get-tweet-by-id/base.ts b/src/toolkits/toolkits/twitter/tools/get-tweet-by-id/base.ts new file mode 100644 index 00000000..a9103c62 --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/get-tweet-by-id/base.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import { createBaseTool } from "@/toolkits/create-tool"; + +export const getTweetByIdTool = createBaseTool({ + description: + "Get detailed information about a specific tweet by ID. Rate limit: 900 requests per 15 minutes.", + inputSchema: z.object({ + tweet_id: z.string().describe("ID of the tweet to retrieve"), + }), + outputSchema: z.object({ + tweet: z.object({ + id: z.string(), + text: z.string(), + author_id: z.string(), + created_at: z.string(), + public_metrics: z + .object({ + retweet_count: z.number(), + reply_count: z.number(), + like_count: z.number(), + quote_count: z.number(), + bookmark_count: z.number().optional(), + impression_count: z.number().optional(), + }) + .optional(), + entities: z + .object({ + hashtags: z.array(z.object({ tag: z.string() })).optional(), + mentions: z.array(z.object({ username: z.string() })).optional(), + urls: z + .array( + z.object({ + url: z.string(), + expanded_url: z.string().optional(), + display_url: z.string().optional(), + }), + ) + .optional(), + }) + .optional(), + referenced_tweets: z + .array( + z.object({ + type: z.enum(["retweeted", "quoted", "replied_to"]), + id: z.string(), + }), + ) + .optional(), + }), + rate_limit: z + .object({ + limit: z.number(), + remaining: z.number(), + reset: z.number(), + }) + .optional(), + }), +}); diff --git a/src/toolkits/toolkits/twitter/tools/get-tweet-by-id/client.tsx b/src/toolkits/toolkits/twitter/tools/get-tweet-by-id/client.tsx new file mode 100644 index 00000000..2be1a533 --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/get-tweet-by-id/client.tsx @@ -0,0 +1,121 @@ +import React from "react"; +import { createClientTool } from "@/toolkits/create-tool"; +import { getTweetByIdTool } from "./base"; + +export const getTweetByIdToolConfigClient = createClientTool( + getTweetByIdTool, + { + CallComponent: () => ( +

Get Tweet by ID

+ ), + ResultComponent: ({ result: { tweet, rate_limit } }) => ( +
+

Tweet Details

+ + {rate_limit && rate_limit.remaining < 100 && ( +
+
+ ⚠️ Rate Limit Warning +
+
+ {rate_limit.remaining} of {rate_limit.limit} requests remaining. +
+
+ )} + +
+
{tweet.text}
+ +
+
Tweet ID: {tweet.id}
+
Author ID: {tweet.author_id}
+
Created: {new Date(tweet.created_at).toLocaleString()}
+
+ + {tweet.public_metrics && ( +
+
+ ❤️ + {tweet.public_metrics.like_count.toLocaleString()} +
+
+ 🔄 + + {tweet.public_metrics.retweet_count.toLocaleString()} + +
+
+ 💬 + {tweet.public_metrics.reply_count.toLocaleString()} +
+
+ 💭 + {tweet.public_metrics.quote_count.toLocaleString()} +
+ {tweet.public_metrics.bookmark_count !== undefined && ( +
+ 🔖 + + {tweet.public_metrics.bookmark_count.toLocaleString()} + +
+ )} +
+ )} + + {tweet.entities?.hashtags && tweet.entities.hashtags.length > 0 && ( +
+
+ Hashtags: +
+
+ {tweet.entities.hashtags.map((tag, idx) => ( + + #{tag.tag} + + ))} +
+
+ )} + + {tweet.entities?.mentions && tweet.entities.mentions.length > 0 && ( +
+
+ Mentions: +
+
+ {tweet.entities.mentions.map((mention, idx) => ( + + @{mention.username} + + ))} +
+
+ )} + + {tweet.referenced_tweets && tweet.referenced_tweets.length > 0 && ( +
+
+ References: +
+ {tweet.referenced_tweets.map((ref, idx) => ( +
+ {ref.type === "retweeted" && "🔄 Retweet of "} + {ref.type === "quoted" && "💭 Quote of "} + {ref.type === "replied_to" && "💬 Reply to "} + tweet {ref.id} +
+ ))} +
+ )} +
+
+ ), + }, +); diff --git a/src/toolkits/toolkits/twitter/tools/get-tweet-by-id/server.ts b/src/toolkits/toolkits/twitter/tools/get-tweet-by-id/server.ts new file mode 100644 index 00000000..3fef24e0 --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/get-tweet-by-id/server.ts @@ -0,0 +1,45 @@ +import type { TwitterApi } from "twitter-api-v2"; +import type { getTweetByIdTool } from "./base"; +import type { ServerToolConfig } from "@/toolkits/types"; + +export const getTweetByIdToolConfigServer = ( + client: TwitterApi, +): ServerToolConfig< + typeof getTweetByIdTool.inputSchema.shape, + typeof getTweetByIdTool.outputSchema.shape +> => { + return { + callback: async ({ tweet_id }) => { + const response = await client.v2.singleTweet(tweet_id, { + "tweet.fields": [ + "created_at", + "public_metrics", + "entities", + "author_id", + "referenced_tweets", + ], + }); + + const rateLimit = (response as any).rateLimit + ? { + limit: (response as any).rateLimit.limit, + remaining: (response as any).rateLimit.remaining, + reset: (response as any).rateLimit.reset, + } + : undefined; + + return { + tweet: { + id: response.data.id, + text: response.data.text, + author_id: response.data.author_id!, + created_at: response.data.created_at!, + public_metrics: response.data.public_metrics, + entities: response.data.entities, + referenced_tweets: response.data.referenced_tweets, + }, + rate_limit: rateLimit, + }; + }, + }; +}; diff --git a/src/toolkits/toolkits/twitter/tools/index.ts b/src/toolkits/toolkits/twitter/tools/index.ts index 892f7b26..386283cf 100644 --- a/src/toolkits/toolkits/twitter/tools/index.ts +++ b/src/toolkits/toolkits/twitter/tools/index.ts @@ -1,7 +1,17 @@ export enum TwitterTools { GetUserProfile = "get-user-profile", GetLatestTweets = "get-latest-tweets", + SearchTweets = "search-tweets", + GetBookmarks = "get-bookmarks", + AddBookmark = "add-bookmark", + RemoveBookmark = "remove-bookmark", + GetTweetById = "get-tweet-by-id", } export * from "./profile/base"; export * from "./tweets/base"; +export * from "./search-tweets/base"; +export * from "./get-bookmarks/base"; +export * from "./add-bookmark/base"; +export * from "./remove-bookmark/base"; +export * from "./get-tweet-by-id/base"; diff --git a/src/toolkits/toolkits/twitter/tools/remove-bookmark/base.ts b/src/toolkits/toolkits/twitter/tools/remove-bookmark/base.ts new file mode 100644 index 00000000..9ffde9fd --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/remove-bookmark/base.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { createBaseTool } from "@/toolkits/create-tool"; + +export const removeBookmarkTool = createBaseTool({ + description: + "Remove a tweet from your bookmarks. Rate limit: 50 requests per 15 minutes. Requires bookmark.write scope.", + inputSchema: z.object({ + tweet_id: z.string().describe("ID of the tweet to remove from bookmarks"), + }), + outputSchema: z.object({ + removed: z.boolean(), + tweet_id: z.string(), + rate_limit: z + .object({ + limit: z.number(), + remaining: z.number(), + reset: z.number(), + }) + .optional(), + }), +}); diff --git a/src/toolkits/toolkits/twitter/tools/remove-bookmark/client.tsx b/src/toolkits/toolkits/twitter/tools/remove-bookmark/client.tsx new file mode 100644 index 00000000..d8c98a19 --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/remove-bookmark/client.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { createClientTool } from "@/toolkits/create-tool"; +import { removeBookmarkTool } from "./base"; + +export const removeBookmarkToolConfigClient = createClientTool( + removeBookmarkTool, + { + CallComponent: () => ( +

Remove Bookmark

+ ), + ResultComponent: ({ result: { removed, tweet_id, rate_limit } }) => ( +
+

Bookmark Removed

+ + {rate_limit && rate_limit.remaining < 10 && ( +
+
+ ⚠️ Rate Limit Warning +
+
+ {rate_limit.remaining} of {rate_limit.limit} requests remaining. +
+
+ )} + +
+ {removed ? ( +
+
✓ Success
+
+ Tweet {tweet_id} has been removed from bookmarks. +
+
+ ) : ( +
+
✗ Failed
+
+ Could not remove bookmark for tweet {tweet_id}. +
+
+ )} +
+
+ ), + }, +); diff --git a/src/toolkits/toolkits/twitter/tools/remove-bookmark/server.ts b/src/toolkits/toolkits/twitter/tools/remove-bookmark/server.ts new file mode 100644 index 00000000..1302120a --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/remove-bookmark/server.ts @@ -0,0 +1,30 @@ +import type { TwitterApi } from "twitter-api-v2"; +import type { removeBookmarkTool } from "./base"; +import type { ServerToolConfig } from "@/toolkits/types"; + +export const removeBookmarkToolConfigServer = ( + client: TwitterApi, +): ServerToolConfig< + typeof removeBookmarkTool.inputSchema.shape, + typeof removeBookmarkTool.outputSchema.shape +> => { + return { + callback: async ({ tweet_id }) => { + const response = await client.v2.deleteBookmark(tweet_id); + + const rateLimit = (response as any).rateLimit + ? { + limit: (response as any).rateLimit.limit, + remaining: (response as any).rateLimit.remaining, + reset: (response as any).rateLimit.reset, + } + : undefined; + + return { + removed: response.data.bookmarked === false, + tweet_id, + rate_limit: rateLimit, + }; + }, + }; +}; diff --git a/src/toolkits/toolkits/twitter/tools/search-tweets/base.ts b/src/toolkits/toolkits/twitter/tools/search-tweets/base.ts new file mode 100644 index 00000000..94b3924a --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/search-tweets/base.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; +import { createBaseTool } from "@/toolkits/create-tool"; + +export const searchTweetsTool = createBaseTool({ + description: + "Search for tweets using keywords, hashtags, or advanced queries. Rate limit: 450 requests per 15 minutes. Supports operators like 'from:', '#hashtag', 'lang:', etc.", + inputSchema: z.object({ + query: z + .string() + .describe( + "Search query. Supports operators: from:username, to:username, #hashtag, lang:en, -filter:retweets, etc.", + ), + max_results: z + .number() + .min(10) + .max(100) + .default(10) + .describe("Number of tweets to return (10-100)"), + start_time: z + .string() + .optional() + .describe("Start time in ISO 8601 format (e.g., 2024-01-01T00:00:00Z)"), + end_time: z + .string() + .optional() + .describe("End time in ISO 8601 format"), + sort_order: z + .enum(["recency", "relevancy"]) + .optional() + .describe("Sort order for results"), + }), + outputSchema: z.object({ + tweets: z.array( + z.object({ + id: z.string(), + text: z.string(), + author_id: z.string(), + created_at: z.string(), + public_metrics: z + .object({ + retweet_count: z.number(), + reply_count: z.number(), + like_count: z.number(), + quote_count: z.number(), + bookmark_count: z.number().optional(), + impression_count: z.number().optional(), + }) + .optional(), + entities: z + .object({ + hashtags: z.array(z.object({ tag: z.string() })).optional(), + mentions: z + .array(z.object({ username: z.string() })) + .optional(), + urls: z + .array( + z.object({ + url: z.string(), + expanded_url: z.string().optional(), + }), + ) + .optional(), + }) + .optional(), + }), + ), + meta: z.object({ + result_count: z.number(), + next_token: z.string().optional(), + }), + rate_limit: z + .object({ + limit: z.number(), + remaining: z.number(), + reset: z.number(), + }) + .optional(), + }), +}); diff --git a/src/toolkits/toolkits/twitter/tools/search-tweets/client.tsx b/src/toolkits/toolkits/twitter/tools/search-tweets/client.tsx new file mode 100644 index 00000000..accfeb93 --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/search-tweets/client.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { createClientTool } from "@/toolkits/create-tool"; +import { searchTweetsTool } from "./base"; + +export const searchTweetsToolConfigClient = createClientTool( + searchTweetsTool, + { + CallComponent: () => ( +

Search Tweets

+ ), + ResultComponent: ({ result: { tweets, meta, rate_limit } }) => ( +
+

Search Results

+ + {/* Rate Limit Warning */} + {rate_limit && rate_limit.remaining < 50 && ( +
+
+ ⚠️ Rate Limit Warning +
+
+ {rate_limit.remaining} of {rate_limit.limit} requests remaining. + Resets at{" "} + {new Date(rate_limit.reset * 1000).toLocaleTimeString()}. +
+
+ )} + + {tweets.length === 0 ? ( +
+ No tweets found matching your query. +
+ ) : ( +
+
+ Found {meta.result_count} tweets (showing {tweets.length}): +
+
+ {tweets.map((tweet) => ( +
+
+ {tweet.text} +
+
+
+ {new Date(tweet.created_at).toLocaleDateString()} +
+ {tweet.public_metrics && ( +
+ ❤️ {tweet.public_metrics.like_count} + 🔄 {tweet.public_metrics.retweet_count} + 💬 {tweet.public_metrics.reply_count} +
+ )} +
+ {tweet.entities?.hashtags && + tweet.entities.hashtags.length > 0 && ( +
+ {tweet.entities.hashtags.map((tag, idx) => ( + + #{tag.tag} + + ))} +
+ )} +
+ ))} +
+ {meta.next_token && ( +
+ More results available (use pagination) +
+ )} +
+ )} +
+ ), + }, +); diff --git a/src/toolkits/toolkits/twitter/tools/search-tweets/server.ts b/src/toolkits/toolkits/twitter/tools/search-tweets/server.ts new file mode 100644 index 00000000..d0549099 --- /dev/null +++ b/src/toolkits/toolkits/twitter/tools/search-tweets/server.ts @@ -0,0 +1,52 @@ +import type { TwitterApi } from "twitter-api-v2"; +import type { searchTweetsTool } from "./base"; +import type { ServerToolConfig } from "@/toolkits/types"; + +export const searchTweetsToolConfigServer = ( + client: TwitterApi, +): ServerToolConfig< + typeof searchTweetsTool.inputSchema.shape, + typeof searchTweetsTool.outputSchema.shape +> => { + return { + callback: async ({ query, max_results, start_time, end_time, sort_order }) => { + const response = await client.v2.search(query, { + max_results: max_results || 10, + start_time, + end_time, + sort_order, + "tweet.fields": [ + "created_at", + "public_metrics", + "entities", + "author_id", + ], + }); + + // Extract rate limit info from response + const rateLimit = response.rateLimit + ? { + limit: response.rateLimit.limit, + remaining: response.rateLimit.remaining, + reset: response.rateLimit.reset, + } + : undefined; + + return { + tweets: response.data.data.map((tweet) => ({ + id: tweet.id, + text: tweet.text, + author_id: tweet.author_id!, + created_at: tweet.created_at!, + public_metrics: tweet.public_metrics, + entities: tweet.entities, + })), + meta: { + result_count: response.data.meta.result_count, + next_token: response.data.meta.next_token, + }, + rate_limit: rateLimit, + }; + }, + }; +}; diff --git a/src/toolkits/toolkits/twitter/tools/server.ts b/src/toolkits/toolkits/twitter/tools/server.ts index a223f6b0..ad3bf0a3 100644 --- a/src/toolkits/toolkits/twitter/tools/server.ts +++ b/src/toolkits/toolkits/twitter/tools/server.ts @@ -1,2 +1,7 @@ export * from "./profile/server"; export * from "./tweets/server"; +export * from "./search-tweets/server"; +export * from "./get-bookmarks/server"; +export * from "./add-bookmark/server"; +export * from "./remove-bookmark/server"; +export * from "./get-tweet-by-id/server";