diff --git a/.eslintrc.json b/.eslintrc.json index 9f08ddf..8444d58 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -12,6 +12,12 @@ "require-await": "off" } }, + { + "files": "src/utils/sendToSupabase.ts", + "rules": { + "camelcase": "off" + } + }, { "files": "src/contexts/productBoard.ts", "rules": { diff --git a/package.json b/package.json index 6e16f1b..6849593 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "tsc", "lint": "eslint src test --max-warnings 0 && prettier src test --check", "start": "node -r dotenv/config prod/index.js", + "dev": "tsc-watch -p . --noClear --onSuccess \"node -r dotenv/config prod/index.js\"", "test": "ts-mocha -u tdd test/**/*.spec.ts --recursive --exit --timeout 10000", "fly-secrets": "fly secrets set $(cat .env | xargs)" }, @@ -33,6 +34,7 @@ "homepage": "https://github.com/nhcarrigan/deepgram-bot#readme", "dependencies": { "@octokit/graphql": "7.1.0", + "@supabase/supabase-js": "2.43.2", "discord.js": "14.14.1", "dotenv": "16.4.5", "node-fetch": "2", @@ -54,6 +56,7 @@ "mocha": "10.4.0", "prettier": "2.8.8", "ts-mocha": "10.0.0", + "tsc-watch": "6.2.0", "typescript": "5.4.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bf94e1..90f9254 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ dependencies: '@octokit/graphql': specifier: 7.1.0 version: 7.1.0 + '@supabase/supabase-js': + specifier: 2.43.2 + version: 2.43.2 discord.js: specifier: 14.14.1 version: 14.14.1 @@ -67,6 +70,9 @@ devDependencies: ts-mocha: specifier: 10.0.0 version: 10.0.0(mocha@10.4.0) + tsc-watch: + specifier: 6.2.0 + version: 6.2.0(typescript@5.4.5) typescript: specifier: 5.4.5 version: 5.4.5 @@ -380,6 +386,63 @@ packages: engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false + /@supabase/auth-js@2.64.2: + resolution: {integrity: sha512-s+lkHEdGiczDrzXJ1YWt2y3bxRi+qIUnXcgkpLSrId7yjBeaXBFygNjTaoZLG02KNcYwbuZ9qkEIqmj2hF7svw==} + dependencies: + '@supabase/node-fetch': 2.6.15 + dev: false + + /@supabase/functions-js@2.3.1: + resolution: {integrity: sha512-QyzNle/rVzlOi4BbVqxLSH828VdGY1RElqGFAj+XeVypj6+PVtMlD21G8SDnsPQDtlqqTtoGRgdMlQZih5hTuw==} + dependencies: + '@supabase/node-fetch': 2.6.15 + dev: false + + /@supabase/node-fetch@2.6.15: + resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==} + engines: {node: 4.x || >=6.0.0} + dependencies: + whatwg-url: 5.0.0 + dev: false + + /@supabase/postgrest-js@1.15.2: + resolution: {integrity: sha512-9/7pUmXExvGuEK1yZhVYXPZnLEkDTwxgMQHXLrN5BwPZZm4iUCL1YEyep/Z2lIZah8d8M433mVAUEGsihUj5KQ==} + dependencies: + '@supabase/node-fetch': 2.6.15 + dev: false + + /@supabase/realtime-js@2.9.5: + resolution: {integrity: sha512-TEHlGwNGGmKPdeMtca1lFTYCedrhTAv3nZVoSjrKQ+wkMmaERuCe57zkC5KSWFzLYkb5FVHW8Hrr+PX1DDwplQ==} + dependencies: + '@supabase/node-fetch': 2.6.15 + '@types/phoenix': 1.6.4 + '@types/ws': 8.5.10 + ws: 8.14.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + + /@supabase/storage-js@2.5.5: + resolution: {integrity: sha512-OpLoDRjFwClwc2cjTJZG8XviTiQH4Ik8sCiMK5v7et0MDu2QlXjCAW3ljxJB5+z/KazdMOTnySi+hysxWUPu3w==} + dependencies: + '@supabase/node-fetch': 2.6.15 + dev: false + + /@supabase/supabase-js@2.43.2: + resolution: {integrity: sha512-F9CljeJBo5aPucNhrLoMnpEHi5yqNZ0vH0/CL4mGy+/Ggr7FUrYErVJisa1NptViqyhs1HGNzzwjOYG6626h8g==} + dependencies: + '@supabase/auth-js': 2.64.2 + '@supabase/functions-js': 2.3.1 + '@supabase/node-fetch': 2.6.15 + '@supabase/postgrest-js': 1.15.2 + '@supabase/realtime-js': 2.9.5 + '@supabase/storage-js': 2.5.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + /@types/chai@4.3.14: resolution: {integrity: sha512-Wj71sXE4Q4AkGdG9Tvq1u/fquNz9EdG4LIJMwVVII7ashjD/8cf8fyIfJAjRr6YcsXnSE8cOGQPq1gqeR8z+3w==} dev: true @@ -415,6 +478,10 @@ packages: dependencies: undici-types: 5.26.5 + /@types/phoenix@1.6.4: + resolution: {integrity: sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA==} + dev: false + /@types/semver@7.5.0: resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} dev: true @@ -423,6 +490,12 @@ packages: resolution: {integrity: sha512-txGIh+0eDFzKGC25zORnswy+br1Ha7hj5cMVwKIU7+s0U2AxxJru/jZSMU6OC9MJWP6+pc/hc6ZjyZShpsyY2g==} dev: false + /@types/ws@8.5.10: + resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} + dependencies: + '@types/node': 18.19.31 + dev: false + /@types/ws@8.5.9: resolution: {integrity: sha512-jbdrY0a8lxfdTp/+r7Z4CkycbOFN8WX+IOchLJr3juT/xzbJ8URyTVSJ/hvNdadTgM1mnedb47n+Y31GsFnQlg==} dependencies: @@ -1005,6 +1078,10 @@ packages: engines: {node: '>=12'} dev: false + /duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + dev: true + /ejs@3.1.9: resolution: {integrity: sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==} engines: {node: '>=0.10.0'} @@ -1323,6 +1400,18 @@ packages: engines: {node: '>=0.10.0'} dev: true + /event-stream@3.3.4: + resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} + dependencies: + duplexer: 0.1.2 + from: 0.1.7 + map-stream: 0.1.0 + pause-stream: 0.0.11 + split: 0.3.3 + stream-combiner: 0.0.4 + through: 2.3.8 + dev: true + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1423,6 +1512,10 @@ packages: mime-types: 2.1.35 dev: true + /from@0.1.7: + resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} + dev: true + /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true @@ -1915,6 +2008,10 @@ packages: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} dev: true + /map-stream@0.1.0: + resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} + dev: true + /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2006,6 +2103,10 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true + /node-cleanup@2.1.2: + resolution: {integrity: sha512-qN8v/s2PAJwGUtr1/hYTpNKlD6Y9rc4p8KSmJXyGdYGZsDGKXrGThikLFP9OCHFeLeEpQzPwiAtdIvBLqm//Hw==} + dev: true + /node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2132,6 +2233,12 @@ packages: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} dev: true + /pause-stream@0.0.11: + resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + dependencies: + through: 2.3.8 + dev: true + /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -2155,6 +2262,14 @@ packages: hasBin: true dev: true + /ps-tree@1.2.0: + resolution: {integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==} + engines: {node: '>= 0.10'} + hasBin: true + dependencies: + event-stream: 3.3.4 + dev: true + /punycode@2.3.0: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} engines: {node: '>=6'} @@ -2333,10 +2448,27 @@ packages: resolution: {integrity: sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==} dev: true + /split@0.3.3: + resolution: {integrity: sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==} + dependencies: + through: 2.3.8 + dev: true + /stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} dev: false + /stream-combiner@0.0.4: + resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} + dependencies: + duplexer: 0.1.2 + dev: true + + /string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + dev: true + /string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2422,6 +2554,10 @@ packages: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true + /through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + dev: true + /to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2469,6 +2605,20 @@ packages: yn: 2.0.0 dev: true + /tsc-watch@6.2.0(typescript@5.4.5): + resolution: {integrity: sha512-2LBhf9kjKXnz7KQ/puLHlozMzzUNHAdYBNMkg3eksQJ9GBAgMg8czznM83T5PmsoUvDnXzfIeQn2lNcIYDr8LA==} + engines: {node: '>=12.12.0'} + hasBin: true + peerDependencies: + typescript: '*' + dependencies: + cross-spawn: 7.0.3 + node-cleanup: 2.1.2 + ps-tree: 1.2.0 + string-argv: 0.3.2 + typescript: 5.4.5 + dev: true + /tsconfig-paths@3.14.2: resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} dependencies: diff --git a/src/config/IntentOptions.ts b/src/config/IntentOptions.ts index 2bcd7cc..5f073db 100644 --- a/src/config/IntentOptions.ts +++ b/src/config/IntentOptions.ts @@ -1,7 +1,12 @@ import { GatewayIntentBits } from "discord.js"; -const { Guilds, GuildMessages, MessageContent, GuildMembers } = - GatewayIntentBits; +const { + Guilds, + GuildMessages, + MessageContent, + GuildMembers, + GuildMessageReactions, +} = GatewayIntentBits; export const IntentOptions = [ Guilds, @@ -12,4 +17,5 @@ export const IntentOptions = [ * the member cache updated on join/leave, for the answer command. */ GuildMembers, + GuildMessageReactions, ]; diff --git a/src/database.types.ts b/src/database.types.ts new file mode 100644 index 0000000..df6dca3 --- /dev/null +++ b/src/database.types.ts @@ -0,0 +1,131 @@ +type PublicSchema = Database[Extract]; + +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +export type Database = { + public: { + Tables: { + community_raw: { + Row: { + created_at: string; + id: number; + origin: string; + raw: Json; + source: string; + }; + Insert: { + created_at?: string; + id?: number; + origin: string; + raw: Json; + source: string; + }; + Update: { + created_at?: string; + id?: number; + origin?: string; + raw?: Json; + source?: string; + }; + Relationships: []; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; +export type Tables< + PublicTableNameOrOptions extends + | keyof (PublicSchema["Tables"] & PublicSchema["Views"]) + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] & + Database[PublicTableNameOrOptions["schema"]]["Views"]) + : never = never +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] & + Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R; + } + ? R + : never + : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & + PublicSchema["Views"]) + ? (PublicSchema["Tables"] & + PublicSchema["Views"])[PublicTableNameOrOptions] extends { + Row: infer R; + } + ? R + : never + : never; + +export type TablesInsert< + PublicTableNameOrOptions extends + | keyof PublicSchema["Tables"] + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] + : never = never +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I; + } + ? I + : never + : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] + ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { + Insert: infer I; + } + ? I + : never + : never; + +export type TablesUpdate< + PublicTableNameOrOptions extends + | keyof PublicSchema["Tables"] + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] + : never = never +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U; + } + ? U + : never + : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] + ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { + Update: infer U; + } + ? U + : never + : never; + +export type Enums< + PublicEnumNameOrOptions extends + | keyof PublicSchema["Enums"] + | { schema: keyof Database }, + EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"] + : never = never +> = PublicEnumNameOrOptions extends { schema: keyof Database } + ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] + : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] + ? PublicSchema["Enums"][PublicEnumNameOrOptions] + : never; diff --git a/src/events/action.types.ts b/src/events/action.types.ts new file mode 100644 index 0000000..e7fd7ce --- /dev/null +++ b/src/events/action.types.ts @@ -0,0 +1,8 @@ +export const enum ACTION { + THREAD_DELETE = "thread_delete", + THREAD_UPDATE = "thread_update", + THREAD_CREATE = "thread_create", + MESSAGE_CREATE = "message_create", + MESSAGE_UPDATE = "message_update", + MESSAGE_DELETE = "message_delete", +} diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts new file mode 100644 index 0000000..111d350 --- /dev/null +++ b/src/events/messageCreate.ts @@ -0,0 +1,31 @@ +import { ChannelType, Message } from "discord.js"; + +import { ExtendedClient } from "../interfaces/ExtendedClient"; +import { errorHandler } from "../utils/errorHandler"; +import { sendMessageToSupabase } from "../utils/sendToSupabase"; +import { sleep } from "../utils/sleep"; + +import { ACTION } from "./action.types"; + +/** + * Handles the thread create event. + * + * @param {ExtendedClient} bot The bot's Discord instance. + * @param {Message} message The thread channel payload from Discord. + */ +export const messageCreate = async (bot: ExtendedClient, message: Message) => { + try { + if ( + message.channel && + (message.channel.type !== ChannelType.PublicThread || + message.channel.parentId !== bot.cache.helpChannel.id) + ) { + return; + } + await sendMessageToSupabase(ACTION.MESSAGE_CREATE, bot, message); + + await sleep(1000); + } catch (err) { + await errorHandler(bot, "message create", err); + } +}; diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts new file mode 100644 index 0000000..0d19aaf --- /dev/null +++ b/src/events/messageDelete.ts @@ -0,0 +1,33 @@ +import { ChannelType, Message, PartialMessage } from "discord.js"; + +import { ExtendedClient } from "../interfaces/ExtendedClient"; +import { errorHandler } from "../utils/errorHandler"; +import { sendMessageToSupabase } from "../utils/sendToSupabase"; +import { sleep } from "../utils/sleep"; + +import { ACTION } from "./action.types"; + +/** + * Handles the thread create event. + * + * @param {ExtendedClient} bot The bot's Discord instance. + * @param {Message | PartialMessage} message The thread channel payload from Discord. + */ +export const messageDelete = async ( + bot: ExtendedClient, + message: Message | PartialMessage +) => { + try { + if ( + message.thread && + (message.channel.type !== ChannelType.PublicThread || + message.channel.parentId !== bot.cache.helpChannel.id) + ) { + return; + } + await sendMessageToSupabase(ACTION.MESSAGE_DELETE, bot, message); + await sleep(1000); + } catch (err) { + await errorHandler(bot, "message create", err); + } +}; diff --git a/src/events/messageUpdate.ts b/src/events/messageUpdate.ts new file mode 100644 index 0000000..a58e768 --- /dev/null +++ b/src/events/messageUpdate.ts @@ -0,0 +1,34 @@ +import { ChannelType, Message, PartialMessage } from "discord.js"; + +import { ExtendedClient } from "../interfaces/ExtendedClient"; +import { errorHandler } from "../utils/errorHandler"; +import { sendMessageToSupabase } from "../utils/sendToSupabase"; +import { sleep } from "../utils/sleep"; + +import { ACTION } from "./action.types"; + +/** + * Handles the thread create event. + * + * @param {ExtendedClient} bot The bot's Discord instance. + * @param {Message | PartialMessage} message The thread channel payload from Discord. + */ +export const messageUpdate = async ( + bot: ExtendedClient, + message: Message | PartialMessage +) => { + try { + if ( + message.thread && + (message.channel.type !== ChannelType.PublicThread || + message.channel.parentId !== bot.cache.helpChannel.id) + ) { + return; + } + + await sendMessageToSupabase(ACTION.MESSAGE_UPDATE, bot, message); + await sleep(1000); + } catch (err) { + await errorHandler(bot, "message update", err); + } +}; diff --git a/src/events/threadCreate.ts b/src/events/threadCreate.ts index 13eb6fb..7398f1f 100644 --- a/src/events/threadCreate.ts +++ b/src/events/threadCreate.ts @@ -3,8 +3,10 @@ import { AnyThreadChannel, ChannelType } from "discord.js"; import { ResponseText } from "../config/ResponseText"; import { ExtendedClient } from "../interfaces/ExtendedClient"; import { errorHandler } from "../utils/errorHandler"; +import { sendThreadToSupabase } from "../utils/sendToSupabase"; import { sleep } from "../utils/sleep"; -import { sendToSupabase } from "../utils/sendToSupabase"; + +import { ACTION } from "./action.types"; /** * Handles the thread create event. @@ -31,8 +33,7 @@ export const threadCreate = async ( /** * We're logging our support thread messages out to supabase for automation purposes. */ - await sendToSupabase("create", bot, thread); - + await sendThreadToSupabase(ACTION.THREAD_CREATE, bot, thread); const isMovedPost = thread.ownerId === bot.user?.id; /** diff --git a/src/events/threadDelete.ts b/src/events/threadDelete.ts index 919d50a..9fd89ca 100644 --- a/src/events/threadDelete.ts +++ b/src/events/threadDelete.ts @@ -2,7 +2,9 @@ import { AnyThreadChannel, ChannelType } from "discord.js"; import { ExtendedClient } from "../interfaces/ExtendedClient"; import { errorHandler } from "../utils/errorHandler"; -import { sendToSupabase } from "../utils/sendToSupabase"; +import { sendThreadToSupabase } from "../utils/sendToSupabase"; + +import { ACTION } from "./action.types"; /** * Handles the thread delete event. @@ -29,7 +31,7 @@ export const threadDelete = async ( /** * We're logging our support thread messages out to supabase for automation purposes. */ - await sendToSupabase("delete", bot, thread); + await sendThreadToSupabase(ACTION.THREAD_DELETE, bot, thread); } catch (err) { await errorHandler(bot, "thread delete", err); } diff --git a/src/events/threadUpdate.ts b/src/events/threadUpdate.ts index 1b12460..5c2a419 100644 --- a/src/events/threadUpdate.ts +++ b/src/events/threadUpdate.ts @@ -2,18 +2,20 @@ import { AnyThreadChannel, ChannelType } from "discord.js"; import { ExtendedClient } from "../interfaces/ExtendedClient"; import { errorHandler } from "../utils/errorHandler"; -import { sendToSupabase } from "../utils/sendToSupabase"; +import { sendThreadToSupabase } from "../utils/sendToSupabase"; + +import { ACTION } from "./action.types"; /** * Handles the thread update event. * * @param {ExtendedClient} bot The bot's Discord instance. - * @param {AnyThreadChannel} thread The thread channel payload from Discord. + * @param {AnyThreadChannel} newThread The thread channel payload from Discord. + * @returns {void} - Void. */ export const threadUpdate = async ( bot: ExtendedClient, - newThread: AnyThreadChannel, - oldThread: AnyThreadChannel + newThread: AnyThreadChannel ) => { try { /** @@ -26,11 +28,10 @@ export const threadUpdate = async ( ) { return; } - + await sendThreadToSupabase(ACTION.THREAD_UPDATE, bot, newThread); /** * We're logging our support thread messages out to supabase for automation purposes. */ - await sendToSupabase("update", bot, { ...newThread, previous: oldThread }); } catch (err) { await errorHandler(bot, "thread update", err); } diff --git a/src/index.ts b/src/index.ts index 43ed6d1..d0ec28e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,14 @@ -import { Client, Events, Message } from "discord.js"; +import { Client, Events, Message, PartialMessage } from "discord.js"; import { scheduleJob } from "node-schedule"; import { IntentOptions } from "./config/IntentOptions"; import { interactionCreate } from "./events/interactionCreate"; +import { messageCreate } from "./events/messageCreate"; +import { messageDelete } from "./events/messageDelete"; +import { messageUpdate } from "./events/messageUpdate"; import { threadCreate } from "./events/threadCreate"; +import { threadDelete } from "./events/threadDelete"; +import { threadUpdate } from "./events/threadUpdate"; import { ExtendedClient } from "./interfaces/ExtendedClient"; import { sendStickyMessage } from "./modules/sendStickyMessage"; import { aggregateDailyUnansweredThreads } from "./modules/threads/aggregateDailyUnansweredThreads"; @@ -18,8 +23,6 @@ import { loadContexts } from "./utils/loadContexts"; import { logHandler } from "./utils/logHandler"; import { registerCommands } from "./utils/registerCommands"; import { validateEnv } from "./utils/validateEnv"; -import { threadUpdate } from "./events/threadUpdate"; -import { threadDelete } from "./events/threadDelete"; (async () => { try { @@ -65,32 +68,38 @@ import { threadDelete } from "./events/threadDelete"; }); bot.on(Events.ThreadCreate, async (thread) => { - console.log(thread); await threadCreate(bot, thread); }); - bot.on(Events.ThreadUpdate, async (newThread, oldThread) => { - console.log(newThread, oldThread); - // await threadUpdate(bot, newThread, oldThread); + bot.on(Events.ThreadUpdate, async (_oldThread, newThread) => { + await threadUpdate(bot, newThread); }); bot.on(Events.ThreadDelete, async (thread) => { - console.log(thread); - // await threadDelete(bot, thread); + await threadDelete(bot, thread); }); bot.on(Events.MessageCreate, async (message: Message) => { - console.log(message, message.channel); + await messageCreate(bot, message); }); - bot.on(Events.MessageUpdate, async (message) => { - console.log(message); - }); + bot.on( + Events.MessageUpdate, + async (_oldMessage, message: Message | PartialMessage) => { + await messageUpdate(bot, message); + } + ); - bot.on(Events.MessageDelete, async (message) => { - console.log(message); + bot.on(Events.MessageDelete, async (message: Message | PartialMessage) => { + await messageDelete(bot, message); }); + // bot.on(Events.MessageReactionAdd, async (_reaction, _user) => { + // }); + + // bot.on(Events.MessageReactionRemove, async (_reaction, _user) => { + // }); + await bot.login(bot.env.token); } catch (err) { const bot = new Client({ intents: IntentOptions }) as ExtendedClient; diff --git a/src/utils/sendToSupabase.ts b/src/utils/sendToSupabase.ts index 088137a..db7f159 100644 --- a/src/utils/sendToSupabase.ts +++ b/src/utils/sendToSupabase.ts @@ -1,10 +1,268 @@ -import { AnyThreadChannel } from "discord.js"; +import { createClient } from "@supabase/supabase-js"; +import { AnyThreadChannel, Message, PartialMessage } from "discord.js"; + +import { Database } from "../database.types"; +import { ACTION } from "../events/action.types"; import { ExtendedClient } from "../interfaces/ExtendedClient"; -export const sendToSupabase = async ( +import { + messageTransform, + threadAction, + TransformMessage, + TransformPost, +} from "./transformers"; + +const timeZone = "America/New_York"; + +const supabase = createClient( + process.env.SUPABASE_URL || "", + process.env.SUPABASE_ANON_KEY || "" +); + +/** + * + * @param {typeof ACTION} action - Create, update or delete. + * @param {ExtendedClient} bot - Bot instance. + * @param {AnyThreadChannel} thread - Thread instance. + * @returns {any} - Any. + */ +export const sendThreadToSupabase = async ( + action: string, + bot: ExtendedClient, + thread: AnyThreadChannel +) => { + switch (action) { + case ACTION.THREAD_CREATE: { + const threadData = await threadAction()[ACTION.THREAD_CREATE](thread); + const threadJson = { + raw: JSON.parse(JSON.stringify(threadData)), + source: "discord", + origin: threadData.id, + created_at: + thread.createdAt?.toLocaleString("en-US", { timeZone }) || + new Date().toLocaleString("en-US", { timeZone }), + }; + const { data, error } = await supabase + .from("community_raw") + .insert([threadJson]) + .select(); + if (error) { + return Promise.reject(error); + } + return data; + } + case ACTION.THREAD_UPDATE: { + const threadData = await threadAction()[ACTION.THREAD_UPDATE](thread); + const threadJson = { + raw: JSON.parse(JSON.stringify(threadData)), + source: "discord", + origin: threadData.id, + created_at: + thread.createdAt?.toLocaleString("en-US", { timeZone }) || + new Date().toLocaleString("en-US", { timeZone }), + }; + + const { data, error } = await supabase + .from("community_raw") + .update({ ...threadJson }) + .eq("origin", threadData.id) + .select(); + + if (error) { + return Promise.reject(error); + } + + return data; + } + case ACTION.THREAD_DELETE: { + const { data, error } = await supabase + .from("community_raw") + .select("*") + .eq("origin", thread.id); + + if (data && data.length > 0) { + const { raw, ...rest } = data[0]; + const deletedRawData = Object.assign( + {}, + JSON.parse(JSON.stringify(raw)), + // How to convert the JSON to object, what is this typescript error + { + deleted: true, + } + ); + + const { data: deletedPost, error: deletePostError } = await supabase + .from("community_raw") + .update({ ...rest, raw: deletedRawData }) + .eq("origin", thread.id) + .select(); + + if (deletePostError) { + return Promise.reject(deletePostError); + } + return deletedPost; + } + if (error) { + return Promise.reject(error); + } + return; + } + default: { + return; + } + } +}; + +/** + * + * @param {typeof ACTION} action - Create, update or delete. + * @param {ExtendedClient} bot - Bot instance. + * @param {Message} message - Message instance. + * @returns {any} - Any. + */ +export const sendMessageToSupabase = async ( action: string, bot: ExtendedClient, - data: any + message: Message | PartialMessage ) => { - console.log(action, bot, data); + switch (action) { + case ACTION.MESSAGE_CREATE: { + const threadId = message.channel.id; + if (threadId) { + const { data: threadData, error } = await supabase + .from("community_raw") + .select("*") + .eq("origin", threadId); + if (error) { + return Promise.reject(error); + } + if (threadData && threadData.length > 0) { + const { raw } = threadData[0]; + let { messages = [] } = JSON.parse( + JSON.stringify(raw) + ) as TransformPost; + const transformedMessage = messageTransform(message); + let found = false; + messages = messages.reduce( + (msgs: TransformMessage[], m: TransformMessage) => { + if (m.id === transformedMessage.id) { + found = true; + msgs.push({ ...transformedMessage }); + } else { + msgs.push(m); + } + return msgs; + }, + [] + ); + if (!found) { + messages.push(transformedMessage); + const { data: updatedPost, error: createMessageError } = + await supabase + .from("community_raw") + .update({ raw: Object.assign({}, raw, { messages }) }) + .eq("origin", threadId) + .select(); + if (createMessageError) { + return Promise.reject(createMessageError); + } + return updatedPost; + } + } + } + return Promise.reject("Message doesn't belong to helpChannel"); + } + case ACTION.MESSAGE_UPDATE: { + // Same as message create, let it be repeating + // we never know what to add in future + const threadId = message.channel.id; + if (threadId) { + const { data: threadData, error } = await supabase + .from("community_raw") + .select("*") + .eq("origin", threadId); + if (error) { + return Promise.reject(error); + } + if (threadData && threadData.length > 0) { + const { raw } = threadData[0]; + let { messages } = JSON.parse(JSON.stringify(raw)) as TransformPost; + const transformedMessage = messageTransform(message); + let found = false; + messages = messages.reduce( + (msgs: TransformMessage[], m: TransformMessage) => { + if (m.id === transformedMessage.id) { + found = true; + // Transformed message can be partial, so we need the + // current values of message from raw + msgs.push({ ...m, ...transformedMessage }); + } else { + msgs.push(m); + } + return msgs; + }, + [] + ); + if (!found) { + messages.push(transformedMessage); + const { data: updatedPost, error: createMessageError } = + await supabase + .from("community_raw") + .update({ raw: Object.assign({}, raw, { messages }) }) + .eq("origin", threadId) + .select(); + if (createMessageError) { + return Promise.reject(createMessageError); + } + return updatedPost; + } + } + } + return Promise.reject("Message doesn't belong to helpChannel"); + } + case ACTION.MESSAGE_DELETE: { + const threadId = message.channel.id; + if (threadId) { + const { data: threadData, error } = await supabase + .from("community_raw") + .select("*") + .eq("origin", threadId); + if (error) { + return Promise.reject(error); + } + if (threadData && threadData.length > 0) { + const { raw } = threadData[0]; + let { messages } = JSON.parse(JSON.stringify(raw)) as TransformPost; + const transformedMessage = messageTransform(message); + messages = messages.reduce( + (msgs: TransformMessage[], m: TransformMessage) => { + if (m.id === transformedMessage.id) { + // Transformed message can be partial, so we need the + // current values of message from raw + msgs.push({ ...m, ...transformedMessage, deleted: true }); + } else { + msgs.push(m); + } + return msgs; + }, + [] + ); + const { data: updatedPost, error: createMessageError } = + await supabase + .from("community_raw") + .update({ raw: Object.assign({}, raw, { messages }) }) + .eq("origin", threadId) + .select(); + if (createMessageError) { + return Promise.reject(createMessageError); + } + return updatedPost; + } + } + return Promise.reject("Message doesn't belong to helpChannel"); + } + default: { + return; + } + } }; diff --git a/src/utils/transformers.ts b/src/utils/transformers.ts new file mode 100644 index 0000000..4426fa3 --- /dev/null +++ b/src/utils/transformers.ts @@ -0,0 +1,158 @@ +import { + Attachment, + HexColorString, + Message, + PartialMessage, + ThreadChannel, +} from "discord.js"; + +import { ACTION } from "../events/action.types"; + +export type TransformPost = { + id: string; + name: string; + archived: string; + type: number; + parentId: string; + locked: boolean; + archiveTimestamp: number; + messageCount: number; + memberCount: number; + totalMessageSent: number; + appliedTags: string[]; + createdAt: string; + url: string; + createdTimestamp: number; + messages: TransformMessage[]; + deleted?: boolean; +}; + +export type TransformMessage = { + id: string; + channelId: string; + guildId: string; + createdTimestamp: number; + type: number; + system: boolean; + content: string; + pinned: boolean; + tts: boolean; + cleanContent: string; + attachments: Attachment[]; + author: TransformUser; + reactions: TransformMessageReaction[]; + url: string; + deleted?: boolean; +}; + +export type TransformUser = { + id: string; + bot: boolean; + system: boolean; + username: string; + discriminator: string; + globalName: string; + avatar: string | null; + banner?: string | null; + defaultAvatarURL: string; + avatarDecoration: string | null; + createdTimestamp: number; + hexAccentColor?: HexColorString | null; + displayName: string; + tag: string; + avatarDecorationURL: string | null; // call + avatarURL: string | null; // call + bannerURL: string | null; // call +}; + +export type TransformEmoji = { + animated: boolean | null; + name: string | null; + id: string | null; + identifier: string; + imageURL: string | null; +}; + +export type TransformMessageReaction = { + count: number; + emoji: TransformEmoji; + me: boolean; + message: TransformMessage; +}; + +export type ThreadActionReturn = { + [ACTION.THREAD_CREATE]: (thread: ThreadChannel) => TransformPost; + [ACTION.THREAD_UPDATE]: (thread: ThreadChannel) => TransformPost; + [ACTION.THREAD_DELETE]: (thread: ThreadChannel) => void; +}; +/** + * + * @param {Message | PartialMessage} message - Transform Message to JSON. + * @returns {TransformMessage} - Returns transformed Message. + */ +export const messageTransform = (message: Message | PartialMessage) => { + const messageJson = message.toJSON() as TransformMessage; + messageJson["url"] = message.url; + // mSs.push(messageJson); + messageJson.attachments = []; + for (const [, attachmentValue] of message.attachments) { + const attachmentJson = attachmentValue; + messageJson.attachments.push(attachmentJson); + } + const messageAuthor = message.author; + if (messageAuthor) { + const messageAuthorJson = messageAuthor.toJSON() as TransformUser; + const messageAuthorValue = { + ...messageAuthorJson, + avatarDecorationURL: messageAuthor.avatarDecorationURL(), + avatarURL: messageAuthor.avatarURL(), + bannerUrl: messageAuthor.bannerURL(), + }; + messageJson.author = messageAuthorValue; + } + for (const [, messageReactionValue] of message.reactions.cache) { + const messageReactionJson = + messageReactionValue.toJSON() as TransformMessageReaction; + const messageReactionJsonEmoji = messageReactionJson["emoji"] || {}; + messageReactionJsonEmoji.imageURL = messageReactionValue.emoji.imageURL(); + messageJson.reactions.push(messageReactionJson); + } + return messageJson; +}; + +/** + * @returns {ThreadActionReturn} Returns object which can used as per the action. + */ +export const threadAction = () => { + return { + [ACTION.THREAD_CREATE]: async (thread: ThreadChannel) => { + const threadData = thread.toJSON() as TransformPost; + threadData["url"] = thread.url; + const customMessages = []; + const messages = await thread.messages.fetch(); + for (const [, messageValue] of messages.entries()) { + const messageJson = await messageTransform(messageValue); + customMessages.push(messageJson); + } + threadData.messages = customMessages; + return threadData; + }, + [ACTION.THREAD_UPDATE]: async (thread: ThreadChannel) => { + // Same as create + const threadData = thread.toJSON() as TransformPost; + const customMessages = []; + const messages = await thread.messages.fetch(); + for (const [, messageValue] of messages.entries()) { + const messageJson = await messageTransform(messageValue); + customMessages.push(messageJson); + } + threadData.messages = customMessages; + return threadData; + }, + [ACTION.THREAD_DELETE]: async () => { + // There is no attribute in ThreadChannel which says if thread is + // deleted or not, means if we tried fetching messages from deleted + // thread, it will throw error + }, + }; +};