Stripe の支払いと Discord のロール管理をシームレスに統合する方法を紹介します。このチャプターでは、支払い完了時の Discord ロールの自動割り当てや、定期購読に基づくロールの更新など、経済的な価値に基づくコミュニティ管理の技術を展開。サーバーレスアプリケーションでの効果的なユーザー経験を構築します。
この章では会員制コミュニティの構築に必要な最低限の機能を実装します。
- ボタンアクションから支払いリンクを作成
- Stripe の支払い管理
- Discord のロール管理
ここで membership
という名前の Discord ロールを作成します。
ボタンアクションから支払いリンクを作成
Discord のボタンアクションから、新しいプライベートチャンネルを作成し、
支払いリンクをスレッドへ投稿します。
memberMessage.ts
関数を作成し、ボタンメッセージを作成します。
functions/skeet/src/lib/discord/messages/memberMessage.ts
import {
ButtonStyleTypes,
MessageComponentTypes,
} from '@skeet-framework/discord-utils'
export const memberMessage = () => {
const body = {
content: 'ボタンを押してね!',
components: [
{
type: MessageComponentTypes.ACTION_ROW,
components: [
{
type: MessageComponentTypes.BUTTON,
style: ButtonStyleTypes.PRIMARY,
label: '📝 メンバーになる',
custom_id: 'member-button',
},
],
},
],
}
return body
}
Stripe のカスタムパラメーターの設定
paymentLinkMessage.ts
関数を作成し、支払いリンクを作成します。
functions/skeet/src/lib/discord/messages/paymentLinkMessage.ts
import { ButtonStyleTypes, MessageComponentTypes } from 'discord-interactions'
export const paymentLinkMessage = (discordUserId: string) => {
const paymentLink = `https://buy.stripe.com/test_cN2bJj1XBbG3cN2fZ0?client_reference_id=${discordUserId}&locale=ja`
const body = {
content: `メンバーになるにはこちらのリンクから支払いをしてください!`,
components: [
{
type: MessageComponentTypes.ACTION_ROW,
components: [
{
type: MessageComponentTypes.BUTTON,
style: ButtonStyleTypes.LINK,
label: 'お支払いリンク',
url: paymentLink,
},
],
},
],
}
return body
}
ここでは前の章で作成した ペイメントリンクを使用します。
そして、URL パラメーターに client_reference_id
を追加することで、
Discord のユーザー ID を Stripe の顧客 ID 情報と照合できるようにします。
locale
を ja
に設定することで、日本語の支払い画面を表示します。
メンバーボタンアクションの登録
次に、memberAction.ts
関数を作成し、ボタンアクションを定義します。
このアクションでは StripeUser
が作成されていなければ作成し、
プライベートチャンネルに新たにボタンメッセージを送信します。
functions/skeet/src/lib/discord/actions/memberAction.ts
import {
DiscordRouterParams,
messageChannel,
} from '@skeet-framework/discord-utils'
import { Response } from 'firebase-functions/v1'
import { add, get } from '@skeet-framework/firestore'
import {
createPrivateChannel,
deferResponse,
updateResponse,
} from '@skeet-framework/discord-utils'
import { StripeUser, StripeUserCN } from '@/models/stripeUserModels'
import { paymentLinkMessage } from '../messages/paymentLinkMessage'
export const memberAction = async (
res: Response,
db: FirebaseFirestore.Firestore,
discordToken: string,
body: DiscordRouterParams
) => {
await deferResponse(discordToken, body.id, body.token)
const memberId = body.member.user.id
const username = body.member.user.username
const stripeUser = await get<StripeUser>(db, StripeUserCN, memberId)
if (stripeUser && stripeUser.isActivated) {
const embed = {
description: `⚠️ すでに会員です。`,
// yellow
color: 0xffee00,
}
await updateResponse(discordToken, body.application_id, body.token, {
embeds: [embed],
flags: 64,
})
return
}
const channelName = `payment-${username}`
const channel = await createPrivateChannel(
discordToken,
body.guild_id,
channelName,
memberId
)
if (channel) {
const stripeUserParams: StripeUser = {
customerId: null,
username,
email: '',
isActivated: false,
expirationDate: null,
}
console.log({ stripeUserParams })
await add<StripeUser>(db, StripeUserCN, stripeUserParams, memberId)
await messageChannel(discordToken, channel.id, paymentLinkMessage(memberId))
const channelNumber = channel?.id || ''
console.log({ channelNumber })
const embed = {
description: `✅ 新しいチャンネルが作成されました - <#${channelNumber}>`,
color: 0x00ff00,
}
const content = {
embeds: [embed],
flags: 64,
}
await updateResponse(discordToken, body.application_id, body.token, content)
return
} else {
await updateResponse(discordToken, body.application_id, body.token, {
content: `⚠️ Failed to create a channel.Please try again in a few seconds.`,
flags: 64,
})
return
}
}
DiscordRouter の更新
そして discordRouter
エンドポイントに memberAction
を登録します。
functions/skeet/src/routings/http/discordRouter.ts
...
} else {
// Button action
const { custom_id } = req.body.data as ActionData
if (custom_id.match(/^register-button$/)) {
await registerAction(res, db, DISCORD_TOKEN.value(), req.body)
}
if (custom_id.match(/^view-button$/)) {
await viewAction(res, db, DISCORD_TOKEN.value(), req.body)
}
if (custom_id.match(/^bonus-button$/)) {
await bonusAction(res, db, DISCORD_TOKEN.value(), req.body)
}
if (custom_id.match(/^member-button$/)) {
await memberAction(res, db, DISCORD_TOKEN.value(), req.body)
}
}
...
アプリをデプロイして、変更を反映させます。
skeet deploy --function skeet:discordRouter
テスト用の root.ts
を以下のように変更します。
import { onRequest } from 'firebase-functions/v2/https'
import { publicHttpOption } from '@/routings/options'
import { TypedRequestBody } from '@/types/http'
import { RootParams } from '@/types/http/rootParams'
import { messageChannel } from '@skeet-framework/discord-utils'
import { defineSecret } from 'firebase-functions/params'
import { memberMessage } from '@/lib/discord/messages/memberMessage'
const DISCORD_TOKEN = defineSecret('DISCORD_TOKEN')
export const root = onRequest(
{ ...publicHttpOption, secrets: [DISCORD_TOKEN] },
async (req: TypedRequestBody<RootParams>, res) => {
try {
// Define your logic here
const message = memberMessage()
const channelId = 'your-channel-id'
await messageChannel(DISCORD_TOKEN.value(), channelId, message)
res.json({ status: 'success' })
} catch (error) {
res.status(500).json({ status: 'error', message: String(error) })
}
},
)
アプリを起動して、ボタンメッセージを送信します。
skeet s
テストようのroot
エンドポイントにアクセスします。
http://127.0.0.1:5001/your-project-id/asia-northeast1/root
そしてボタンを押すと
無事にチャンネルが作成され、支払いリンクが投稿されました。
Stripe Webhook 受信時に membership ロールの付与
支払いが完了したときに、ユーザーに membership
ロールを付与します。
subscription 関数を更新します。
functions/skeet/src/lib/stripe/webhook/subscription.ts
import { DISCORD_GUILD_ID, MEMBERSHIP_ROLE_ID } from '@/lib/config'
import { StripeUser, StripeUserCN } from '@/models/stripeUserModels'
import { addRoleToUser } from '@skeet-framework/discord-utils'
import { get, update } from '@skeet-framework/firestore'
import { addMonths, utcNow } from '@skeet-framework/utils'
import Stripe from 'stripe'
export const subscription = async (
db: FirebaseFirestore.Firestore,
discordToken: string,
eventObject: Stripe.Checkout.Session
) => {
console.log('subscription event handler')
const discordUserId = eventObject.client_reference_id as string
// Get StripeUser by discordUserId
const stripeUser = await get<StripeUser>(db, StripeUserCN, discordUserId)
if (!stripeUser) {
console.log('stripeUser not found')
return false
}
// Add Discord Role
await addRoleToUser(
discordToken,
DISCORD_GUILD_ID,
discordUserId,
MEMBERSHIP_ROLE_ID
)
// Update StripeUser
const today = utcNow()
const oneMonthLater = addMonths(today, 1)
await update<StripeUser>(db, StripeUserCN, discordUserId, {
isActivated: true,
customerId: String(eventObject.customer),
expirationDate: oneMonthLater,
})
return true
}
StripeRouter に DiscordToken を追加
functions/skeet/src/routings/http/stripeRouter.ts
// Discord Token を追加
const DISCORD_TOKEN = defineSecret('DISCORD_TOKEN')
export const stripeRouter = onRequest(
{
...publicHttpOption,
secrets: [
STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET_KEY,
STRIPE_WEBHOOK_SECRET_KEY_TEST,
STRIPE_SECRET_KEY_TEST,
DISCORD_TOKEN,
],
},
...
そして subscription 関数の引数を更新します。
...
try {
//let event = req.body as Stripe.Event
const event = await getVerifiedStripeEvent(req, stripe, secret, payload)
switch (event.type) {
case 'checkout.session.completed': {
const eventObject = event.data.object
if (eventObject.mode === 'subscription') {
await subscription(db, DISCORD_TOKEN.value(), eventObject)
}
if (eventObject.mode === 'payment') {
await payment(db, eventObject)
}
break
}
default:
break
}
...
アプリをデプロイして、変更を反映させます。
skeet deploy --function skeet:stripeRouter,skeet:discordRouter
Firebase エミュレーターで 統合テスト
skeet s
でアプリを起動します。
skeet s
別ウィンドウで、Stripe CLI を起動します。
stripe listen --forward-to localhost:5001/skeet/us-central1/stripeRouter
テストユーザーを Firebase エミュレーターに登録します。
テスト用に自分の Discord ユーザー ID を登録します。
それではメンバーボタンから新規チャンネルを作成し、
テスト用の支払いを完了させてみます。
無事にロールが付与されました 🎉