diff --git a/apps/frontend/public/icons/platforms/mewe.png b/apps/frontend/public/icons/platforms/mewe.png new file mode 100644 index 00000000..0f0f1034 Binary files /dev/null and b/apps/frontend/public/icons/platforms/mewe.png differ diff --git a/apps/frontend/src/components/launches/continue.integration.tsx b/apps/frontend/src/components/launches/continue.integration.tsx index ea3830e5..99aa967f 100644 --- a/apps/frontend/src/components/launches/continue.integration.tsx +++ b/apps/frontend/src/components/launches/continue.integration.tsx @@ -77,6 +77,19 @@ export const ContinueIntegration: FC<{ }; } + if (provider === 'mewe') { + const hash = + typeof window !== 'undefined' + ? window.location.hash.substring(1) + : ''; + const hashParams = new URLSearchParams(hash); + return { + state: hashParams.get('state') || searchParams.state || '', + code: hashParams.get('loginRequestToken') || '', + refresh: searchParams.refresh || '', + }; + } + return searchParams; }, []); diff --git a/apps/frontend/src/components/new-launch/providers/mewe/mewe.group.select.tsx b/apps/frontend/src/components/new-launch/providers/mewe/mewe.group.select.tsx new file mode 100644 index 00000000..717baaa2 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/mewe/mewe.group.select.tsx @@ -0,0 +1,62 @@ +'use client'; + +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'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; + +export const MeweGroupSelect: FC<{ + name: string; + onChange: (event: { + target: { + value: string; + name: string; + }; + }) => void; +}> = (props) => { + const { onChange, name } = props; + const t = useT(); + const customFunc = useCustomProviderFunction(); + const [groups, setGroups] = useState([]); + const { getValues } = useSettings(); + const [currentGroup, setCurrentGroup] = useState(); + + const onChangeInner = (event: { + target: { + value: string; + name: string; + }; + }) => { + setCurrentGroup(event.target.value); + onChange(event); + }; + + useEffect(() => { + customFunc.get('groups').then((data) => setGroups(data)); + const settings = getValues()[props.name]; + if (settings) { + setCurrentGroup(settings); + } + }, []); + + if (!groups.length) { + return null; + } + + return ( + + ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/mewe/mewe.provider.tsx b/apps/frontend/src/components/new-launch/providers/mewe/mewe.provider.tsx new file mode 100644 index 00000000..8aadca6c --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/mewe/mewe.provider.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { + PostComment, + withProvider, +} from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { FC } from 'react'; +import { MeweDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/mewe.dto'; +import { MeweGroupSelect } from '@gitroom/frontend/components/new-launch/providers/mewe/mewe.group.select'; +import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values'; + +const MeweComponent: FC = () => { + const form = useSettings(); + return ( +
+ +
+ ); +}; + +export default withProvider({ + postComment: PostComment.POST, + comments: false, + minimumCharacters: [], + SettingsComponent: MeweComponent, + CustomPreviewComponent: undefined, + dto: MeweDto, + checkValidity: undefined, + maximumCharacters: 63206, +}); diff --git a/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx b/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx index 3ade8870..91844429 100644 --- a/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx +++ b/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx @@ -38,6 +38,7 @@ import GmbProvider from '@gitroom/frontend/components/new-launch/providers/gmb/g import MoltbookProvider from '@gitroom/frontend/components/new-launch/providers/moltbook/moltbook.provider'; import SkoolProvider from '@gitroom/frontend/components/new-launch/providers/skool/skool.provider'; import WhopProvider from '@gitroom/frontend/components/new-launch/providers/whop/whop.provider'; +import MeweProvider from '@gitroom/frontend/components/new-launch/providers/mewe/mewe.provider'; export const Providers = [ { @@ -167,7 +168,11 @@ export const Providers = [ { identifier: 'whop', component: WhopProvider, - } + }, + { + identifier: 'mewe', + component: MeweProvider, + }, ]; export const ShowAllProviders = forwardRef((props, ref) => { const { date, current, global, selectedIntegrations, allIntegrations } = diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts index 071be782..a66261e1 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts @@ -23,6 +23,7 @@ import { FacebookDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-sett import { MoltbookDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/moltbook.dto'; import { SkoolDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/skool.dto'; import { WhopDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/whop.dto'; +import { MeweDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/mewe.dto'; export type ProviderExtension = { __type: T } & M; export type AllProvidersSettings = @@ -57,6 +58,7 @@ export type AllProvidersSettings = | ProviderExtension<'moltbook', MoltbookDto> | ProviderExtension<'vk', None> | ProviderExtension<'skool', SkoolDto> + | ProviderExtension<'mewe', MeweDto> | ProviderExtension<'whop', WhopDto>; type None = NonNullable; @@ -95,6 +97,7 @@ export const allProviders = (setEmpty?: any) => { { value: MoltbookDto, name: 'moltbook' }, { value: SkoolDto, name: 'skool' }, { value: WhopDto, name: 'whop' }, + { value: MeweDto, name: 'mewe' }, ].filter((f) => f.value); }; diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/mewe.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/mewe.dto.ts new file mode 100644 index 00000000..f1039bae --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/mewe.dto.ts @@ -0,0 +1,12 @@ +import { IsDefined, IsString, MinLength } from 'class-validator'; +import { JSONSchema } from 'class-validator-jsonschema'; + +export class MeweDto { + @MinLength(1) + @IsDefined() + @IsString() + @JSONSchema({ + description: 'Group must be an id', + }) + group: string; +} diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts index 731bdbe5..a09ea2f6 100644 --- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts +++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts @@ -35,6 +35,7 @@ import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.ab import { MoltbookProvider } from '@gitroom/nestjs-libraries/integrations/social/moltbook.provider'; import { SkoolProvider } from '@gitroom/nestjs-libraries/integrations/social/skool.provider'; import { WhopProvider } from '@gitroom/nestjs-libraries/integrations/social/whop.provider'; +import { MeweProvider } from '@gitroom/nestjs-libraries/integrations/social/mewe.provider'; export const socialIntegrationList: Array = [ new XProvider(), @@ -69,6 +70,7 @@ export const socialIntegrationList: Array = [ new MoltbookProvider(), new WhopProvider(), new SkoolProvider(), + new MeweProvider(), // new MastodonCustomProvider(), ]; diff --git a/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts new file mode 100644 index 00000000..55640527 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts @@ -0,0 +1,297 @@ +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'; +import { MeweDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/mewe.dto'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; + +export class MeweProvider extends SocialAbstract implements SocialProvider { + identifier = 'mewe'; + name = 'MeWe'; + isBetweenSteps = false; + scopes = [] as string[]; + editor = 'normal' as const; + dto = MeweDto; + + private get meweHost() { + return process.env.MEWE_HOST || 'https://mewe.com'; + } + + private authHeaders(apiToken: string) { + return { + 'X-App-Id': process.env.MEWE_APP_ID!, + 'X-Api-Key': process.env.MEWE_API_KEY!, + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }; + } + + maxLength() { + return 63206; + } + + override handleErrors( + body: string + ): + | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string } + | undefined { + if (body.indexOf('Unauthorized') > -1) { + return { + type: 'refresh-token' as const, + value: 'Access token expired, please re-authenticate', + }; + } + + if (body.indexOf('Enhance Your Calm') > -1 || body.indexOf('420') > -1) { + return { + type: 'retry' as const, + value: 'Rate limited, retrying...', + }; + } + + if (body.indexOf('Forbidden') > -1) { + return { + type: 'bad-body' as const, + value: 'Insufficient permissions for this action', + }; + } + + return undefined; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: + `${this.meweHost}/login` + + `?client_id=${process.env.MEWE_APP_ID}` + + `&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/mewe` + )}` + + `&state=${state}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const loginRequestToken = params.code; + + if (!loginRequestToken) { + return 'No login request token received. Please try again.'; + } + + try { + // Exchange loginRequestToken for apiToken + const tokenResponse = await fetch( + `${this.meweHost}/api/dev/token?loginRequestToken=${loginRequestToken}`, + { + method: 'GET', + headers: { + 'X-App-Id': process.env.MEWE_APP_ID!, + 'X-Api-Key': process.env.MEWE_API_KEY!, + }, + } + ); + + if (!tokenResponse.ok) { + return 'Failed to exchange token. Please try again.'; + } + + const tokenData = await tokenResponse.json(); + + if (tokenData.pending) { + return 'Login request is still pending. Please approve on MeWe and try again.'; + } + + if (!tokenData.apiToken) { + return 'No API token received. Please try again.'; + } + + const apiToken = tokenData.apiToken; + const expiresAt = tokenData.expiresAt; + + // Fetch user profile + const profileResponse = await fetch(`${this.meweHost}/api/dev/me`, { + method: 'GET', + headers: this.authHeaders(apiToken), + }); + + if (!profileResponse.ok) { + return 'Failed to fetch MeWe profile.'; + } + + const profile = await profileResponse.json(); + + const expiresIn = expiresAt + ? dayjs(expiresAt).unix() - dayjs().unix() + : dayjs().add(30, 'days').unix() - dayjs().unix(); + + return { + id: profile.userId, + name: + profile.name || + `${profile.firstName || ''} ${profile.lastName || ''}`.trim(), + accessToken: apiToken, + refreshToken: '', + expiresIn, + picture: '', + username: profile.handle || '', + }; + } catch (e) { + console.log(e); + return 'MeWe authentication failed. Please try again.'; + } + } + + @Tool({ description: 'Groups', dataSchema: [] }) + async groups( + accessToken: string, + params: any, + id: string, + integration: Integration + ) { + try { + const allGroups: any[] = []; + let nextUrl: string | null = `${this.meweHost}/api/dev/groups`; + + while (nextUrl) { + const response = await fetch(nextUrl, { + method: 'GET', + headers: this.authHeaders(accessToken), + }); + + if (!response.ok) break; + + const data = await response.json(); + allGroups.push(...(data.groups || [])); + nextUrl = data.nextPage ? `${this.meweHost}${data.nextPage}` : null; + } + + return allGroups.map((group: any) => ({ + id: String(group.groupId), + name: group.name, + })); + } catch (err) { + return []; + } + } + + private async uploadPhoto( + accessToken: string, + mediaPath: string + ): Promise { + const mediaResponse = await fetch(mediaPath); + const blob = await mediaResponse.blob(); + const fileName = mediaPath.split('/').pop() || 'photo.jpg'; + + const form = new FormData(); + form.append('file', blob, fileName); + + const uploadResponse = await fetch( + `${this.meweHost}/api/dev/photo/upload`, + { + method: 'POST', + headers: { + 'X-App-Id': process.env.MEWE_APP_ID!, + 'X-Api-Key': process.env.MEWE_API_KEY!, + Authorization: `Bearer ${accessToken}`, + }, + body: form, + } + ); + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text(); + throw new Error(`Photo upload failed: ${errorText}`); + } + + const uploadData = await uploadResponse.json(); + return uploadData.id; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [firstPost] = postDetails; + const groupId = firstPost.settings.group; + + // Upload photos if present (exclude videos) + const imageMedia = + firstPost.media?.filter((m) => !m.path || m.path.indexOf('mp4') === -1) || + []; + + const uploadedPhotoIds: string[] = []; + for (const media of imageMedia) { + const photoId = await this.uploadPhoto(accessToken, media.path); + uploadedPhotoIds.push(photoId); + } + + const postBody: Record = { text: firstPost.message }; + if (uploadedPhotoIds.length > 0) { + postBody.uploadedPhotoIds = uploadedPhotoIds; + } + + // MeWe post endpoint may return 204 (no content), so use raw fetch + const postResponse = await fetch( + `${this.meweHost}/api/dev/group/${groupId}/post`, + { + method: 'POST', + headers: this.authHeaders(accessToken), + body: JSON.stringify(postBody), + } + ); + + if (!postResponse.ok) { + const errorText = await postResponse.text(); + console.log(errorText); + const handleError = this.handleErrors(errorText); + if (handleError) { + throw new Error(handleError.value); + } + throw new Error('Failed to create MeWe post'); + } + + let postId = ''; + try { + const responseData = await postResponse.json(); + postId = responseData.postId || responseData.id || makeId(12); + } catch { + postId = makeId(12); + } + + return [ + { + id: firstPost.id, + postId, + releaseURL: `${this.meweHost}/group/${groupId}`, + status: 'success', + }, + ]; + } +}