Twitch integration (#815)
* twitch account linking; profile page twitch panel; twitch landing page * fix import * twitch logo * save twitch credentials cloud function * use user id instead of bot id, add manifold api endpoint * properly add function to index * Added support for new redirect Twitch auth. * Added clean error handling in case of Twitch link fail. * remove simulator * Removed legacy non-redirect Twitch auth code. Added "add bot to channel" button in user profile and relevant data to user type. * Removed unnecessary imports. * Fixed line endings. * Allow users to modify private user twitchInfo firestore object * Local dev on savetwitchcredentials function Co-authored-by: Phil <phil.bladen@gmail.com> Co-authored-by: Marshall Polaris <marshall@pol.rs>
This commit is contained in:
parent
7144e57c93
commit
a2d61a1daa
|
@ -68,6 +68,11 @@ export type PrivateUser = {
|
|||
/** @deprecated - use notificationSubscriptionTypes */
|
||||
notificationPreferences?: notification_subscribe_types
|
||||
notificationSubscriptionTypes: notification_subscription_types
|
||||
twitchInfo?: {
|
||||
twitchName: string
|
||||
controlToken: string
|
||||
botEnabled?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type notification_destination_types = 'email' | 'browser'
|
||||
|
|
|
@ -77,7 +77,7 @@ service cloud.firestore {
|
|||
allow read: if userId == request.auth.uid || isAdmin();
|
||||
allow update: if (userId == request.auth.uid || isAdmin())
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails','notificationSubscriptionTypes' ]);
|
||||
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails', 'notificationSubscriptionTypes', 'twitchInfo']);
|
||||
}
|
||||
|
||||
match /private-users/{userId}/views/{viewId} {
|
||||
|
|
|
@ -71,6 +71,7 @@ import { stripewebhook, createcheckoutsession } from './stripe'
|
|||
import { getcurrentuser } from './get-current-user'
|
||||
import { acceptchallenge } from './accept-challenge'
|
||||
import { createpost } from './create-post'
|
||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||
|
||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||
return onRequest(opts, handler as any)
|
||||
|
@ -96,6 +97,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
|||
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||
const createPostFunction = toCloudFunction(createpost)
|
||||
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
|
||||
|
||||
export {
|
||||
healthFunction as health,
|
||||
|
@ -119,4 +121,5 @@ export {
|
|||
getCurrentUserFunction as getcurrentuser,
|
||||
acceptChallenge as acceptchallenge,
|
||||
createPostFunction as createpost,
|
||||
saveTwitchCredentials as savetwitchcredentials
|
||||
}
|
||||
|
|
22
functions/src/save-twitch-credentials.ts
Normal file
22
functions/src/save-twitch-credentials.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { newEndpoint, validate } from './api'
|
||||
|
||||
const bodySchema = z.object({
|
||||
twitchInfo: z.object({
|
||||
twitchName: z.string(),
|
||||
controlToken: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
export const savetwitchcredentials = newEndpoint({}, async (req, auth) => {
|
||||
const { twitchInfo } = validate(bodySchema, req.body)
|
||||
const userId = auth.uid
|
||||
|
||||
await firestore.doc(`private-users/${userId}`).update({ twitchInfo })
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe'
|
|||
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||
import { getcurrentuser } from './get-current-user'
|
||||
import { createpost } from './create-post'
|
||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||
|
||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||
const app = express()
|
||||
|
@ -65,6 +66,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
|||
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
||||
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
||||
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
||||
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
|
||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||
addEndpointRoute('/createpost', createpost)
|
||||
|
||||
|
|
133
web/components/profile/twitch-panel.tsx
Normal file
133
web/components/profile/twitch-panel.tsx
Normal file
|
@ -0,0 +1,133 @@
|
|||
import clsx from 'clsx'
|
||||
import { MouseEventHandler, ReactNode, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { LinkIcon } from '@heroicons/react/solid'
|
||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||
import { updatePrivateUser } from 'web/lib/firebase/users'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account'
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { Button, ColorType } from './../button'
|
||||
import { Row } from './../layout/row'
|
||||
import { LoadingIndicator } from './../loading-indicator'
|
||||
|
||||
function BouncyButton(props: {
|
||||
children: ReactNode
|
||||
onClick?: MouseEventHandler<any>
|
||||
color?: ColorType
|
||||
}) {
|
||||
const { children, onClick, color } = props
|
||||
return (
|
||||
<Button
|
||||
color={color}
|
||||
size="lg"
|
||||
onClick={onClick}
|
||||
className="btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function TwitchPanel() {
|
||||
const user = useUser()
|
||||
const privateUser = usePrivateUser()
|
||||
|
||||
const twitchInfo = privateUser?.twitchInfo
|
||||
const twitchName = privateUser?.twitchInfo?.twitchName
|
||||
const twitchToken = privateUser?.twitchInfo?.controlToken
|
||||
const twitchBotConnected = privateUser?.twitchInfo?.botEnabled
|
||||
|
||||
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
|
||||
|
||||
const copyOverlayLink = async () => {
|
||||
copyToClipboard(`http://localhost:1000/overlay?t=${twitchToken}`)
|
||||
toast.success('Overlay link copied!', {
|
||||
icon: linkIcon,
|
||||
})
|
||||
}
|
||||
|
||||
const copyDockLink = async () => {
|
||||
copyToClipboard(`http://localhost:1000/dock?t=${twitchToken}`)
|
||||
toast.success('Dock link copied!', {
|
||||
icon: linkIcon,
|
||||
})
|
||||
}
|
||||
|
||||
const updateBotConnected = (connected: boolean) => async () => {
|
||||
if (user && twitchInfo) {
|
||||
twitchInfo.botEnabled = connected
|
||||
await updatePrivateUser(user.id, { twitchInfo })
|
||||
}
|
||||
}
|
||||
|
||||
const [twitchLoading, setTwitchLoading] = useState(false)
|
||||
|
||||
const createLink = async () => {
|
||||
if (!user || !privateUser) return
|
||||
setTwitchLoading(true)
|
||||
|
||||
const promise = linkTwitchAccountRedirect(user, privateUser)
|
||||
track('link twitch from profile')
|
||||
await promise
|
||||
|
||||
setTwitchLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Twitch</label>
|
||||
|
||||
{!twitchName ? (
|
||||
<Row>
|
||||
<Button
|
||||
color="indigo"
|
||||
onClick={createLink}
|
||||
disabled={twitchLoading}
|
||||
>
|
||||
Link your Twitch account
|
||||
</Button>
|
||||
{twitchLoading && <LoadingIndicator className="ml-4" />}
|
||||
</Row>
|
||||
) : (
|
||||
<Row>
|
||||
<span className="mr-4 text-gray-500">Linked Twitch account</span>{' '}
|
||||
{twitchName}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{twitchToken && (
|
||||
<div>
|
||||
<div className="flex w-full">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex grow gap-4',
|
||||
twitchToken ? '' : 'tooltip tooltip-top'
|
||||
)}
|
||||
data-tip="You must link your Twitch account first"
|
||||
>
|
||||
<BouncyButton color="blue" onClick={copyOverlayLink}>
|
||||
Copy overlay link
|
||||
</BouncyButton>
|
||||
<BouncyButton color="indigo" onClick={copyDockLink}>
|
||||
Copy dock link
|
||||
</BouncyButton>
|
||||
{twitchBotConnected ? (
|
||||
<BouncyButton color="red" onClick={updateBotConnected(false)}>
|
||||
Remove bot from your channel
|
||||
</BouncyButton>
|
||||
) : (
|
||||
<BouncyButton color="green" onClick={updateBotConnected(true)}>
|
||||
Add bot to your channel
|
||||
</BouncyButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
9
web/lib/api/api-key.ts
Normal file
9
web/lib/api/api-key.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { updatePrivateUser } from '../firebase/users'
|
||||
|
||||
export const generateNewApiKey = async (userId: string) => {
|
||||
const newApiKey = crypto.randomUUID()
|
||||
|
||||
return await updatePrivateUser(userId, { apiKey: newApiKey })
|
||||
.then(() => newApiKey)
|
||||
.catch(() => undefined)
|
||||
}
|
41
web/lib/twitch/link-twitch-account.ts
Normal file
41
web/lib/twitch/link-twitch-account.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { PrivateUser, User } from 'common/user'
|
||||
import { generateNewApiKey } from '../api/api-key'
|
||||
|
||||
const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately
|
||||
|
||||
export async function initLinkTwitchAccount(
|
||||
manifoldUserID: string,
|
||||
manifoldUserAPIKey: string
|
||||
): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> {
|
||||
const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
manifoldID: manifoldUserID,
|
||||
apiKey: manifoldUserAPIKey,
|
||||
redirectURL: window.location.href,
|
||||
}),
|
||||
})
|
||||
const responseData = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(responseData.message)
|
||||
}
|
||||
const responseFetch = fetch(
|
||||
`${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}`
|
||||
)
|
||||
return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())]
|
||||
}
|
||||
|
||||
export async function linkTwitchAccountRedirect(
|
||||
user: User,
|
||||
privateUser: PrivateUser
|
||||
) {
|
||||
const apiKey = privateUser.apiKey ?? (await generateNewApiKey(user.id))
|
||||
if (!apiKey) throw new Error("Couldn't retrieve or create Manifold api key")
|
||||
|
||||
const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey)
|
||||
|
||||
window.location.href = twitchAuthURL
|
||||
}
|
23
web/pages/api/v0/twitch/save.ts
Normal file
23
web/pages/api/v0/twitch/save.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import {
|
||||
CORS_ORIGIN_MANIFOLD,
|
||||
CORS_ORIGIN_LOCALHOST,
|
||||
} from 'common/envs/constants'
|
||||
import { applyCorsHeaders } from 'web/lib/api/cors'
|
||||
import { fetchBackend, forwardResponse } from 'web/lib/api/proxy'
|
||||
|
||||
export const config = { api: { bodyParser: true } }
|
||||
|
||||
export default async function route(req: NextApiRequest, res: NextApiResponse) {
|
||||
await applyCorsHeaders(req, res, {
|
||||
origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
|
||||
methods: 'POST',
|
||||
})
|
||||
try {
|
||||
const backendRes = await fetchBackend(req, 'savetwitchcredentials')
|
||||
await forwardResponse(res, backendRes)
|
||||
} catch (err) {
|
||||
console.error('Error talking to cloud function: ', err)
|
||||
res.status(500).json({ message: 'Error communicating with backend.' })
|
||||
}
|
||||
}
|
|
@ -12,15 +12,13 @@ import { uploadImage } from 'web/lib/firebase/storage'
|
|||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { User, PrivateUser } from 'common/user'
|
||||
import {
|
||||
getUserAndPrivateUser,
|
||||
updateUser,
|
||||
updatePrivateUser,
|
||||
} from 'web/lib/firebase/users'
|
||||
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
|
||||
import { defaultBannerUrl } from 'web/components/user-page'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||
import { generateNewApiKey } from 'web/lib/api/api-key'
|
||||
import { TwitchPanel } from 'web/components/profile/twitch-panel'
|
||||
|
||||
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
||||
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
|
||||
|
@ -96,11 +94,8 @@ export default function ProfilePage(props: {
|
|||
}
|
||||
|
||||
const updateApiKey = async (e: React.MouseEvent) => {
|
||||
const newApiKey = crypto.randomUUID()
|
||||
setApiKey(newApiKey)
|
||||
await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => {
|
||||
setApiKey(privateUser.apiKey || '')
|
||||
})
|
||||
const newApiKey = await generateNewApiKey(user.id)
|
||||
setApiKey(newApiKey ?? '')
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
|
@ -242,6 +237,8 @@ export default function ProfilePage(props: {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TwitchPanel />
|
||||
</Col>
|
||||
</Col>
|
||||
</Page>
|
||||
|
|
120
web/pages/twitch.tsx
Normal file
120
web/pages/twitch.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
import { Page } from 'web/components/page'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { ManifoldLogo } from 'web/components/nav/manifold-logo'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Button } from 'web/components/button'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account'
|
||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function TwitchLandingPage() {
|
||||
useSaveReferral()
|
||||
useTracking('view twitch landing page')
|
||||
|
||||
const user = useUser()
|
||||
const privateUser = usePrivateUser()
|
||||
const twitchUser = privateUser?.twitchInfo?.twitchName
|
||||
|
||||
const callback =
|
||||
user && privateUser
|
||||
? () => linkTwitchAccountRedirect(user, privateUser)
|
||||
: async () => {
|
||||
const result = await firebaseLogin()
|
||||
|
||||
const userId = result.user.uid
|
||||
const { user, privateUser } = await getUserAndPrivateUser(userId)
|
||||
if (!user || !privateUser) return
|
||||
|
||||
await linkTwitchAccountRedirect(user, privateUser)
|
||||
}
|
||||
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
|
||||
const getStarted = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const promise = callback()
|
||||
track('twitch page button click')
|
||||
await promise
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error('Failed to sign up. Please try again later.')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<SEO
|
||||
title="Manifold Markets on Twitch"
|
||||
description="Get more out of Twitch with play-money betting markets."
|
||||
/>
|
||||
<div className="px-4 pt-2 md:mt-0 lg:hidden">
|
||||
<ManifoldLogo />
|
||||
</div>
|
||||
<Col className="items-center">
|
||||
<Col className="max-w-3xl">
|
||||
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0">
|
||||
<Row className="self-center">
|
||||
<img height={200} width={200} src="/twitch-logo.png" />
|
||||
<img height={200} width={200} src="/flappy-logo.gif" />
|
||||
</Row>
|
||||
<div className="m-4 max-w-[550px] self-center">
|
||||
<h1 className="text-3xl sm:text-6xl xl:text-6xl">
|
||||
<div className="font-semibold sm:mb-2">
|
||||
<span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent">
|
||||
Bet
|
||||
</span>{' '}
|
||||
on your favorite streams
|
||||
</div>
|
||||
</h1>
|
||||
<Spacer h={6} />
|
||||
<div className="mb-4 px-2 ">
|
||||
Get more out of Twitch with play-money betting markets.{' '}
|
||||
{!twitchUser &&
|
||||
'Click the button below to link your Twitch account.'}
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Spacer h={6} />
|
||||
|
||||
{twitchUser ? (
|
||||
<div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 ">
|
||||
<div className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6">
|
||||
<div className="truncate text-sm font-medium text-gray-500">
|
||||
Twitch account linked
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-gray-900">
|
||||
{twitchUser}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<LoadingIndicator spinnerClassName="!w-16 !h-16" />
|
||||
) : (
|
||||
<Button
|
||||
size="2xl"
|
||||
color="gradient"
|
||||
className="self-center"
|
||||
onClick={getStarted}
|
||||
>
|
||||
Get started
|
||||
</Button>
|
||||
)}
|
||||
</Col>
|
||||
</Col>
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
}
|
BIN
web/public/twitch-logo.png
Normal file
BIN
web/public/twitch-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
Loading…
Reference in New Issue
Block a user