diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index a9a92b2d..b0e01043 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -46,21 +46,68 @@ export class IntegrationsController { return { integrations: ( await this._integrationService.getIntegrationsList(org.id) - ).map((p) => ({ - name: p.name, - id: p.id, - internalId: p.internalId, - disabled: p.disabled, - picture: p.picture, - identifier: p.providerIdentifier, - inBetweenSteps: p.inBetweenSteps, - refreshNeeded: p.refreshNeeded, - type: p.type, - time: JSON.parse(p.postingTimes) - })), + ).map((p) => { + const findIntegration = this._integrationManager.getSocialIntegration( + p.providerIdentifier + ); + return { + name: p.name, + id: p.id, + internalId: p.internalId, + disabled: p.disabled, + picture: p.picture, + identifier: p.providerIdentifier, + inBetweenSteps: p.inBetweenSteps, + refreshNeeded: p.refreshNeeded, + type: p.type, + time: JSON.parse(p.postingTimes), + changeProfilePicture: !!findIntegration.changeProfilePicture, + changeNickName: !!findIntegration.changeNickname, + }; + }), }; } + @Post('/:id/nickname') + async setNickname( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string, + @Body() body: { name: string; picture: string } + ) { + const integration = await this._integrationService.getIntegrationById( + org.id, + id + ); + if (!integration) { + throw new Error('Invalid integration'); + } + + const manager = this._integrationManager.getSocialIntegration( + integration.providerIdentifier + ); + if (!manager.changeProfilePicture && !manager.changeNickname) { + throw new Error('Invalid integration'); + } + + const { url } = manager.changeProfilePicture + ? await manager.changeProfilePicture( + integration.internalId, + integration.token, + body.picture + ) + : { url: '' }; + + const { name } = manager.changeNickname + ? await manager.changeNickname( + integration.internalId, + integration.token, + body.name + ) + : { name: '' }; + + return this._integrationService.updateNameAndUrl(id, name, url); + } + @Get('/:id') getSingleIntegration( @Param('id') id: string, @@ -129,7 +176,11 @@ export class IntegrationsController { } if (integrationProvider[body.name]) { - return integrationProvider[body.name](getIntegration.token, body.data); + return integrationProvider[body.name]( + getIntegration.token, + body.data, + getIntegration.internalId + ); } throw new Error('Function not found'); } @@ -144,7 +195,11 @@ export class IntegrationsController { } if (integrationProvider[body.name]) { - return integrationProvider[body.name](getIntegration.token, body.data); + return integrationProvider[body.name]( + getIntegration.token, + body.data, + getIntegration.internalId + ); } throw new Error('Function not found'); } diff --git a/apps/frontend/public/icons/platforms/discord.png b/apps/frontend/public/icons/platforms/discord.png new file mode 100644 index 00000000..90af762a Binary files /dev/null and b/apps/frontend/public/icons/platforms/discord.png differ diff --git a/apps/frontend/public/icons/platforms/slack.png b/apps/frontend/public/icons/platforms/slack.png new file mode 100644 index 00000000..09cb7004 Binary files /dev/null and b/apps/frontend/public/icons/platforms/slack.png differ diff --git a/apps/frontend/src/components/launches/bot.picture.tsx b/apps/frontend/src/components/launches/bot.picture.tsx new file mode 100644 index 00000000..fadac522 --- /dev/null +++ b/apps/frontend/src/components/launches/bot.picture.tsx @@ -0,0 +1,101 @@ +import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; +import React, { FC, FormEventHandler, useCallback, useState } from 'react'; +import { Integrations } from '@gitroom/frontend/components/launches/calendar.context'; +import { useModals } from '@mantine/modals'; +import { Input } from '@gitroom/react/form/input'; +import { Button } from '@gitroom/react/form/button'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { useToaster } from '@gitroom/react/toaster/toaster'; +import { + MediaComponent, + showMediaBox, +} from '@gitroom/frontend/components/media/media.component'; + +export const BotPicture: FC<{ + integration: Integrations; + canChangeProfilePicture: boolean; + canChangeNickName: boolean; + mutate: () => void; +}> = (props) => { + const modal = useModals(); + const toast = useToaster(); + const [nick, setNickname] = useState(props.integration.name); + const [picture, setPicture] = useState(props.integration.picture); + + const fetch = useFetch(); + const submitForm: FormEventHandler = useCallback( + async (e) => { + e.preventDefault(); + await fetch(`/integrations/${props.integration.id}/nickname`, { + method: 'POST', + body: JSON.stringify({ name: nick, picture }), + }); + + props.mutate(); + toast.show('Updated', 'success'); + modal.closeAll(); + }, + [nick, picture, props.mutate] + ); + + const openMedia = useCallback(() => { + showMediaBox((values) => { + setPicture(values.path); + }); + }, []); + + return ( +
+ + + +
+
+ {props.canChangeProfilePicture && ( +
+ Bot Picture + +
+ )} + {props.canChangeNickName && ( + setNickname(e.target.value)} + name="Nickname" + label="Nickname" + placeholder="" + disableForm={true} + /> + )} + +
+ +
+
+
+
+ ); +}; diff --git a/apps/frontend/src/components/launches/calendar.context.tsx b/apps/frontend/src/components/launches/calendar.context.tsx index 901b02f5..2c508e66 100644 --- a/apps/frontend/src/components/launches/calendar.context.tsx +++ b/apps/frontend/src/components/launches/calendar.context.tsx @@ -57,6 +57,8 @@ export interface Integrations { identifier: string; type: string; picture: string; + changeProfilePicture: boolean; + changeNickName: boolean; time: { time: number }[]; } diff --git a/apps/frontend/src/components/launches/launches.component.tsx b/apps/frontend/src/components/launches/launches.component.tsx index d0a5ef52..27cf01dc 100644 --- a/apps/frontend/src/components/launches/launches.component.tsx +++ b/apps/frontend/src/components/launches/launches.component.tsx @@ -94,7 +94,7 @@ export const LaunchesComponent = () => { useEffect(() => { if (typeof window === 'undefined') { - return ; + return; } if (search.get('scope') === 'missing') { toast.show('You have to approve all the channel permissions', 'warning'); @@ -117,7 +117,7 @@ export const LaunchesComponent = () => {
-
+

Channels

{sortedIntegrations.length === 0 && ( @@ -196,6 +196,8 @@ export const LaunchesComponent = () => { {integration.name}
void, + mutate: () => void; onChange: (shouldReload: boolean) => void; }> = (props) => { - const { canEnable, canDisable, id, onChange, mutate } = props; + const { + canEnable, + canDisable, + id, + onChange, + mutate, + canChangeProfilePicture, + canChangeNickName, + } = props; const fetch = useFetch(); const { integrations } = useCalendar(); const toast = useToaster(); @@ -98,8 +109,30 @@ export const Menu: FC<{ withCloseButton: false, closeOnEscape: false, closeOnClickOutside: false, + children: , + }); + setShow(false); + }, [integrations]); + + const changeBotPicture = useCallback(() => { + const findIntegration = integrations.find( + (integration) => integration.id === id + ); + modal.openModal({ + classNames: { + modal: 'w-[100%] max-w-[600px] bg-transparent text-textColor', + }, + size: '100%', + withCloseButton: false, + closeOnEscape: true, + closeOnClickOutside: true, children: ( - + ), }); setShow(false); @@ -128,6 +161,36 @@ export const Menu: FC<{ onClick={(e) => e.stopPropagation()} className={`absolute top-[100%] left-0 p-[8px] px-[20px] bg-fifth flex flex-col gap-[16px] z-[100] rounded-[8px] border border-tableBorder ${interClass} text-nowrap`} > + {(canChangeProfilePicture || canChangeNickName) && ( +
+
+ + + +
+
+ Change Bot{' '} + {[ + canChangeProfilePicture && 'Picture', + canChangeNickName && 'Nickname', + ] + .filter((f) => f) + .join(' / ')} +
+
+ )}
void; +}> = (props) => { + const { onChange, name } = props; + const customFunc = useCustomProviderFunction(); + const [publications, setOrgs] = useState([]); + const { getValues } = useSettings(); + const [currentMedia, setCurrentMedia] = useState(); + + const onChangeInner = (event: { target: { value: string, name: string } }) => { + setCurrentMedia(event.target.value); + onChange(event); + }; + + useEffect(() => { + customFunc.get('channels').then((data) => setOrgs(data)); + const settings = getValues()[props.name]; + if (settings) { + setCurrentMedia(settings); + } + }, []); + + + if (!publications.length) { + return null; + } + + return ( + + ); +}; diff --git a/apps/frontend/src/components/launches/providers/discord/discord.provider.tsx b/apps/frontend/src/components/launches/providers/discord/discord.provider.tsx new file mode 100644 index 00000000..86f00438 --- /dev/null +++ b/apps/frontend/src/components/launches/providers/discord/discord.provider.tsx @@ -0,0 +1,25 @@ +import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider'; +import { FC } from 'react'; +import { DiscordDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/discord.dto'; +import { DiscordChannelSelect } from '@gitroom/frontend/components/launches/providers/discord/discord.channel.select'; +import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values'; + +const Empty: FC = () => { + return null; +}; + +const DiscordComponent: FC = () => { + const form = useSettings(); + return ( +
+ +
+ ); +}; +export default withProvider( + DiscordComponent, + Empty, + DiscordDto, + undefined, + 280 +); diff --git a/apps/frontend/src/components/launches/providers/show.all.providers.tsx b/apps/frontend/src/components/launches/providers/show.all.providers.tsx index e215d71e..50d9392a 100644 --- a/apps/frontend/src/components/launches/providers/show.all.providers.tsx +++ b/apps/frontend/src/components/launches/providers/show.all.providers.tsx @@ -13,6 +13,8 @@ import TiktokProvider from '@gitroom/frontend/components/launches/providers/tikt import PinterestProvider from '@gitroom/frontend/components/launches/providers/pinterest/pinterest.provider'; import DribbbleProvider from '@gitroom/frontend/components/launches/providers/dribbble/dribbble.provider'; import ThreadsProvider from '@gitroom/frontend/components/launches/providers/threads/threads.provider'; +import DiscordProvider from '@gitroom/frontend/components/launches/providers/discord/discord.provider'; +import SlackProvider from '@gitroom/frontend/components/launches/providers/slack/slack.provider'; export const Providers = [ {identifier: 'devto', component: DevtoProvider}, @@ -29,6 +31,8 @@ export const Providers = [ {identifier: 'pinterest', component: PinterestProvider}, {identifier: 'dribbble', component: DribbbleProvider}, {identifier: 'threads', component: ThreadsProvider}, + {identifier: 'discord', component: DiscordProvider}, + {identifier: 'slack', component: SlackProvider}, ]; diff --git a/apps/frontend/src/components/launches/providers/slack/slack.channel.select.tsx b/apps/frontend/src/components/launches/providers/slack/slack.channel.select.tsx new file mode 100644 index 00000000..d07f4260 --- /dev/null +++ b/apps/frontend/src/components/launches/providers/slack/slack.channel.select.tsx @@ -0,0 +1,44 @@ +import { FC, useEffect, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; +import { Select } from '@gitroom/react/form/select'; +import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values'; + +export const SlackChannelSelect: FC<{ + name: string; + onChange: (event: { target: { value: string; name: string } }) => void; +}> = (props) => { + const { onChange, name } = props; + const customFunc = useCustomProviderFunction(); + const [publications, setOrgs] = useState([]); + const { getValues } = useSettings(); + const [currentMedia, setCurrentMedia] = useState(); + + const onChangeInner = (event: { target: { value: string, name: string } }) => { + setCurrentMedia(event.target.value); + onChange(event); + }; + + useEffect(() => { + customFunc.get('channels').then((data) => setOrgs(data)); + const settings = getValues()[props.name]; + if (settings) { + setCurrentMedia(settings); + } + }, []); + + + if (!publications.length) { + return null; + } + + return ( + + ); +}; diff --git a/apps/frontend/src/components/launches/providers/slack/slack.provider.tsx b/apps/frontend/src/components/launches/providers/slack/slack.provider.tsx new file mode 100644 index 00000000..90857a87 --- /dev/null +++ b/apps/frontend/src/components/launches/providers/slack/slack.provider.tsx @@ -0,0 +1,25 @@ +import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider'; +import { FC } from 'react'; +import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values'; +import { SlackChannelSelect } from '@gitroom/frontend/components/launches/providers/slack/slack.channel.select'; +import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/slack.dto'; + +const Empty: FC = () => { + return null; +}; + +const SlackComponent: FC = () => { + const form = useSettings(); + return ( +
+ +
+ ); +}; +export default withProvider( + SlackComponent, + Empty, + SlackDto, + undefined, + 280 +); diff --git a/apps/frontend/src/components/onboarding/connect.channels.tsx b/apps/frontend/src/components/onboarding/connect.channels.tsx index 02987db8..65e5c7f7 100644 --- a/apps/frontend/src/components/onboarding/connect.channels.tsx +++ b/apps/frontend/src/components/onboarding/connect.channels.tsx @@ -242,6 +242,8 @@ export const ConnectChannels: FC = () => { {integration.name}
{ + const { access_token, expires_in, refresh_token } = await ( + await this.fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + body: new URLSearchParams({ + refresh_token: refreshToken, + grant_type: 'refresh_token', + }), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from( + process.env.DISCORD_CLIENT_ID + + ':' + + process.env.DISCORD_CLIENT_SECRET + ).toString('base64')}`, + }, + }) + ).json(); + + const { application } = await ( + await fetch('https://discord.com/api/oauth2/@me', { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + ).json(); + + return { + refreshToken: refresh_token, + expiresIn: expires_in, + accessToken: access_token, + id: '', + name: application.name, + picture: '', + username: '', + }; + } + async generateAuthUrl(refresh?: string) { + const state = makeId(6); + return { + url: `https://discord.com/oauth2/authorize?client_id=${ + process.env.DISCORD_CLIENT_ID + }&permissions=377957124096&response_type=code&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/discord${ + refresh ? `?refresh=${refresh}` : '' + }` + )}&integration_type=0&scope=bot+identify+guilds&state=${state}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const [newCode, guild] = params.code.split(':'); + const { access_token, expires_in, refresh_token, scope } = await ( + await this.fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + body: new URLSearchParams({ + code: newCode, + grant_type: 'authorization_code', + redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/discord`, + }), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from( + process.env.DISCORD_CLIENT_ID + + ':' + + process.env.DISCORD_CLIENT_SECRET + ).toString('base64')}`, + }, + }) + ).json(); + + this.checkScopes(this.scopes, scope.split(' ')); + + const { application } = await ( + await fetch('https://discord.com/api/oauth2/@me', { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + ).json(); + + return { + id: guild, + name: application.name, + accessToken: access_token, + refreshToken: refresh_token, + expiresIn: expires_in, + picture: `https://cdn.discordapp.com/avatars/${application.bot.id}/${application.bot.avatar}.png`, + username: application.bot.username, + }; + } + + async channels(accessToken: string, params: any, id: string) { + const list = await ( + await fetch(`https://discord.com/api/guilds/${id}/channels`, { + headers: { + Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`, + }, + }) + ).json(); + + console.log(list); + + return list + .filter((p: any) => p.type === 0 || p.type === 15) + .map((p: any) => ({ + id: String(p.id), + name: p.name, + })); + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + let channel = postDetails[0].settings.channel; + if (postDetails.length > 1) { + const { id: threadId } = await ( + await fetch( + `https://discord.com/api/channels/${postDetails[0].settings.channel}/threads`, + { + method: 'POST', + headers: { + Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: postDetails[0].message, + auto_archive_duration: 1440, + type: 11, // Public thread type + }), + } + ) + ).json(); + channel = threadId; + } + + const finalData = []; + for (const post of postDetails) { + const form = new FormData(); + form.append( + 'payload_json', + JSON.stringify({ + content: post.message, + attachments: post.media?.map((p, index) => ({ + id: index, + description: `Picture ${index}`, + filename: p.url.split('/').pop(), + })), + }) + ); + + let index = 0; + for (const media of post.media || []) { + const loadMedia = await fetch(media.url); + + form.append( + `files[${index}]`, + await loadMedia.blob(), + media.url.split('/').pop() + ); + index++; + } + + const data = await ( + await fetch(`https://discord.com/api/channels/${channel}/messages`, { + method: 'POST', + headers: { + Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`, + }, + body: form, + }) + ).json(); + + finalData.push({ + id: post.id, + releaseURL: `https://discord.com/channels/${id}/${channel}/${data.id}`, + postId: data.id, + status: 'success', + }); + } + + return finalData; + } + + async changeNickname( + id: string, + accessToken: string, + name: string, + ) { + await (await fetch(`https://discord.com/api/guilds/${id}/members/@me`, { + method: 'PATCH', + headers: { + Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + nick: name, + }) + })).json(); + + return { + name, + } + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts index 452b9123..831dfc38 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts @@ -8,6 +8,7 @@ import { import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider'; import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; export class LinkedinPageProvider extends LinkedinProvider @@ -206,9 +207,10 @@ export class LinkedinPageProvider override async post( id: string, accessToken: string, - postDetails: PostDetails[] + postDetails: PostDetails[], + integration: Integration ): Promise { - return super.post(id, accessToken, postDetails, 'company'); + return super.post(id, accessToken, postDetails, integration, 'company'); } async analytics( diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts index b76db213..ef80d46a 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -13,6 +13,7 @@ import { BadBody, SocialAbstract, } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { Integration } from '@prisma/client'; export class LinkedinProvider extends SocialAbstract implements SocialProvider { identifier = 'linkedin'; @@ -287,6 +288,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider { id: string, accessToken: string, postDetails: PostDetails[], + integration: Integration, type = 'personal' as 'company' | 'personal' ): Promise { const [firstPost, ...restPosts] = postDetails; diff --git a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts new file mode 100644 index 00000000..da34b898 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts @@ -0,0 +1,207 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; + +export class SlackProvider extends SocialAbstract implements SocialProvider { + identifier = 'slack'; + name = 'Slack'; + isBetweenSteps = false; + scopes = ['identify', 'guilds']; + async refreshToken(refreshToken: string): Promise { + const { access_token, expires_in, refresh_token } = await ( + await this.fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + body: new URLSearchParams({ + refresh_token: refreshToken, + grant_type: 'refresh_token', + }), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from( + process.env.DISCORD_CLIENT_ID + + ':' + + process.env.DISCORD_CLIENT_SECRET + ).toString('base64')}`, + }, + }) + ).json(); + + const { application } = await ( + await fetch('https://discord.com/api/oauth2/@me', { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + ).json(); + + return { + refreshToken: refresh_token, + expiresIn: expires_in, + accessToken: access_token, + id: '', + name: application.name, + picture: '', + username: '', + }; + } + async generateAuthUrl(refresh?: string) { + const state = makeId(6); + + return { + url: `https://slack.com/oauth/v2/authorize?client_id=${ + process.env.SLACK_ID + }&redirect_uri=${encodeURIComponent( + `${ + process?.env?.FRONTEND_URL?.indexOf('https') === -1 + ? 'https://redirectmeto.com/' + : '' + }${process?.env?.FRONTEND_URL}/integrations/social/slack${ + refresh ? `?refresh=${refresh}` : '' + }` + )}&scope=channels:read,chat:write,users:read,groups:read,channels:join,chat:write.customize&state=${state}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const { access_token, team, bot_user_id, authed_user, ...all } = await ( + await this.fetch(`https://slack.com/api/oauth.v2.access`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: process.env.SLACK_ID!, + client_secret: process.env.SLACK_SECRET!, + code: params.code, + redirect_uri: `${ + process?.env?.FRONTEND_URL?.indexOf('https') === -1 + ? 'https://redirectmeto.com/' + : '' + }${process?.env?.FRONTEND_URL}/integrations/social/slack${ + params.refresh ? `?refresh=${params.refresh}` : '' + }`, + }), + }) + ).json(); + + const { user } = await ( + await fetch(`https://slack.com/api/users.info?user=${bot_user_id}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + ).json(); + + return { + id: team.id, + name: user.real_name, + accessToken: access_token, + refreshToken: 'null', + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + picture: user.profile.image_48, + username: user.name, + }; + } + + async channels(accessToken: string, params: any, id: string) { + const list = await ( + await fetch( + `https://slack.com/api/conversations.list?types=public_channel,private_channel`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + ).json(); + + return list.channels.map((p: any) => ({ + id: p.id, + name: p.name, + })); + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + await fetch(`https://slack.com/api/conversations.join`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + channel: postDetails[0].settings.channel, + }), + }); + + let lastId = ''; + for (const post of postDetails) { + const { ts } = await ( + await fetch(`https://slack.com/api/chat.postMessage`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + channel: postDetails[0].settings.channel, + username: integration.name, + icon_url: integration.picture, + ...(lastId ? { thread_ts: lastId } : {}), + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: post.message, + }, + }, + ...(post.media?.length + ? post.media.map((m) => ({ + type: 'image', + image_url: m.url, + alt_text: '', + })) + : []), + ], + }), + }) + ).json(); + + lastId = ts; + } + + return []; + } + + async changeProfilePicture(id: string, accessToken: string, url: string) { + return { + url, + }; + } + + async changeNickname(id: string, accessToken: string, name: string) { + return { + name, + }; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts index 35a9c299..e3b232bb 100644 --- a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts +++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts @@ -1,3 +1,5 @@ +import { Integration } from '@prisma/client'; + export interface IAuthenticator { authenticate(params: { code: string; @@ -6,7 +8,21 @@ export interface IAuthenticator { }): Promise; refreshToken(refreshToken: string): Promise; generateAuthUrl(refresh?: string): Promise; - analytics?(id: string, accessToken: string, date: number): Promise; + analytics?( + id: string, + accessToken: string, + date: number + ): Promise; + changeNickname?( + id: string, + accessToken: string, + name: string + ): Promise<{ name: string }>; + changeProfilePicture?( + id: string, + accessToken: string, + url: string + ): Promise<{ url: string }>; } export interface AnalyticsData { @@ -35,7 +51,8 @@ export interface ISocialMediaIntegration { post( id: string, accessToken: string, - postDetails: PostDetails[] + postDetails: PostDetails[], + integration: Integration ): Promise; // Schedules a new post } diff --git a/libraries/react-shared-libraries/src/helpers/image.with.fallback.tsx b/libraries/react-shared-libraries/src/helpers/image.with.fallback.tsx index 430d9b9a..c4ddc1fb 100644 --- a/libraries/react-shared-libraries/src/helpers/image.with.fallback.tsx +++ b/libraries/react-shared-libraries/src/helpers/image.with.fallback.tsx @@ -1,4 +1,4 @@ -import { FC, useState } from 'react'; +import { FC, useEffect, useState } from 'react'; import Image from 'next/image'; interface ImageSrc { @@ -12,6 +12,11 @@ interface ImageSrc { const ImageWithFallback: FC = (props) => { const { src, fallbackSrc, ...rest } = props; const [imgSrc, setImgSrc] = useState(src); + useEffect(() => { + if (src !== imgSrc) { + setImgSrc(src); + } + }, [src]); return (