diff --git a/community/src/api_v0/message/reactions.test.ts b/community/src/api_v0/message/reactions.test.ts index 6ca0587..7130d47 100644 --- a/community/src/api_v0/message/reactions.test.ts +++ b/community/src/api_v0/message/reactions.test.ts @@ -7,7 +7,7 @@ import { LinkedReactionUser, ReactionUsersByEmoji, } from './reactions'; -import type { DiscordMessage } from '../../lib/community/type'; +import type { CommunityMessage } from '../../lib/community/type'; const eventMessage: EventMessageRecord = { id: 'event-1', @@ -19,7 +19,7 @@ const eventMessage: EventMessageRecord = { updatedAt: '2026-07-01T00:00:00Z', }; -const discordMessage: DiscordMessage = { +const discordMessage: CommunityMessage = { id: eventMessage.messageId, channelId: eventMessage.channelId, content: eventMessage.content, @@ -27,7 +27,7 @@ const discordMessage: DiscordMessage = { author: { id: '323456789012345678', username: 'bot', - globalName: null, + displayName: null, bot: true, }, reactions: [ @@ -41,15 +41,15 @@ const reactionUsersByEmoji: ReactionUsersByEmoji[] = [ emoji: '✅', count: 2, users: [ - { id: '423456789012345678', username: 'taro', globalName: 'Taro', bot: false }, - { id: '523456789012345678', username: 'hanako', globalName: null, bot: false }, + { id: '423456789012345678', username: 'taro', displayName: 'Taro', bot: false }, + { id: '523456789012345678', username: 'hanako', displayName: null, bot: false }, ], }, { emoji: '🍱', count: 1, users: [ - { id: '423456789012345678', username: 'taro', globalName: 'Taro', bot: false }, + { id: '423456789012345678', username: 'taro', displayName: 'Taro', bot: false }, ], }, ]; diff --git a/community/src/api_v0/message/reactions.ts b/community/src/api_v0/message/reactions.ts index 588f68e..b3f1f37 100644 --- a/community/src/api_v0/message/reactions.ts +++ b/community/src/api_v0/message/reactions.ts @@ -1,4 +1,4 @@ -import type { DiscordMessage, DiscordReactionUser } from "../../lib/community/type"; +import type { CommunityMessage, CommunityReactionUser } from "../../lib/community/type"; import type { MemberStatus } from "../../../../share/drizzle/schema"; export type EventMessageRecord = { @@ -38,7 +38,7 @@ export type LinkedReactionUser = { export type ReactionUsersByEmoji = { emoji: string; count: number; - users: DiscordReactionUser[]; + users: CommunityReactionUser[]; }; export type ReactionMember = { @@ -76,7 +76,7 @@ export const collectDiscordUserIds = (reactionUsersByEmoji: ReactionUsersByEmoji export const buildMessageReactionSummary = ( eventMessage: EventMessageRecord, - discordMessage: DiscordMessage, + discordMessage: CommunityMessage, reactionUsersByEmoji: ReactionUsersByEmoji[], linkedUsers: LinkedReactionUser[], ) => { @@ -91,7 +91,7 @@ export const buildMessageReactionSummary = ( const reactionMember: ReactionMember = { discordUserId: discordUser.id, discordUsername: discordUser.username, - discordGlobalName: discordUser.globalName, + discordGlobalName: discordUser.displayName, userId: linkedUser?.userId ?? null, userName: linkedUser?.userName ?? null, displayName: linkedUser?.displayName ?? null, diff --git a/community/src/api_v0/user/me/identity/service.test.ts b/community/src/api_v0/user/me/identity/service.test.ts index 07b6e14..defc067 100644 --- a/community/src/api_v0/user/me/identity/service.test.ts +++ b/community/src/api_v0/user/me/identity/service.test.ts @@ -17,7 +17,7 @@ const linkedAccount = { const oauthUser = { id: linkedAccount.accountId, username: 'test-user', - globalName: 'Test User', + displayName: 'Test User', avatarUrl: null, }; const guildMembership = { @@ -30,7 +30,7 @@ const persisted = { provider: 'discord' as const, providerAccountId: linkedAccount.accountId, username: oauthUser.username, - providerDisplayName: oauthUser.globalName, + providerDisplayName: oauthUser.displayName, avatarUrl: null, oauthVerifiedAt: fixedNow, membership: { diff --git a/community/src/api_v0/user/me/identity/service.ts b/community/src/api_v0/user/me/identity/service.ts index 57d85d6..6ff57fe 100644 --- a/community/src/api_v0/user/me/identity/service.ts +++ b/community/src/api_v0/user/me/identity/service.ts @@ -11,8 +11,8 @@ import { getAuth } from '../../../../auth/better-auth'; import { CommunityProviderError } from '../../../../lib/community/error'; import { getCurrentDiscordUser } from '../../../../lib/community/discord/oauth'; import type { - DiscordGuildMembership, - DiscordOAuthUser, + CommunityAccountProfile, + CommunityMembership, } from '../../../../lib/community/type'; import { verifyDiscordIdentityRoute, @@ -27,9 +27,9 @@ type LinkedDiscordAccount = { type PersistVerificationInput = { appUserId: string; linkedAccount: LinkedDiscordAccount; - oauthUser: DiscordOAuthUser; + oauthUser: CommunityAccountProfile; guildId: string; - guildMembership: DiscordGuildMembership | null; + guildMembership: CommunityMembership | null; verifiedAt: string; }; @@ -43,7 +43,7 @@ type IdentityAuthApi = { type VerificationDependencies = { getAuthApi(c: Context): IdentityAuthApi; - getCurrentDiscordUser(accessToken: string): Promise; + getCurrentDiscordUser(accessToken: string): Promise; findLinkedDiscordAccounts( c: Context, userId: string, @@ -80,7 +80,7 @@ const persistVerification: VerificationDependencies['persistVerification'] = asy provider: 'discord', providerAccountId: input.oauthUser.id, username: input.oauthUser.username, - providerDisplayName: input.oauthUser.globalName, + providerDisplayName: input.oauthUser.displayName, avatarUrl: input.oauthUser.avatarUrl, oauthVerifiedAt: input.verifiedAt, lastSyncedAt: input.verifiedAt, @@ -92,7 +92,7 @@ const persistVerification: VerificationDependencies['persistVerification'] = asy authAccountId: input.linkedAccount.id, providerAccountId: input.oauthUser.id, username: input.oauthUser.username, - providerDisplayName: input.oauthUser.globalName, + providerDisplayName: input.oauthUser.displayName, avatarUrl: input.oauthUser.avatarUrl, oauthVerifiedAt: input.verifiedAt, lastSyncedAt: input.verifiedAt, @@ -222,7 +222,7 @@ export const createVerifyDiscordIdentityService = ( const [linkedAccount] = linkedAccounts; let accessToken: string; - let oauthUser: DiscordOAuthUser; + let oauthUser: CommunityAccountProfile; try { ({ accessToken } = await authApi.getAccessToken({ diff --git a/community/src/lib/community/discord/main.ts b/community/src/lib/community/discord/main.ts index ba894ad..9d0806e 100644 --- a/community/src/lib/community/discord/main.ts +++ b/community/src/lib/community/discord/main.ts @@ -1,5 +1,5 @@ import type { CommunityProvider } from '../interface'; -import type { DiscordGuildMembership, DiscordMessage, DiscordReactionUser, Role, SendMessageInput, SendMessageResult } from '../type'; +import type { CommunityMembership, CommunityMessage, CommunityReactionUser, CommunityRole, SendMessageInput, SendMessageResult } from '../type'; import { CommunityProviderError } from '../error'; import { getGuildMembershipAPI, listUserRolesAPI } from './role'; import { getMessageAPI, listMessageReactionUsersAPI, sendMessageAPI } from './message'; @@ -31,11 +31,11 @@ export class DiscordProvider implements CommunityProvider { return response.json(); } - async listUserRoles(userId: string): Promise { + async listUserRoles(userId: string): Promise { return listUserRolesAPI(this, userId); } - async getGuildMembership(userId: string): Promise { + async getGuildMembership(userId: string): Promise { return getGuildMembershipAPI(this, userId); } @@ -43,11 +43,11 @@ export class DiscordProvider implements CommunityProvider { return sendMessageAPI(this, input); } - async getMessage(channelId: string, messageId: string): Promise { + async getMessage(channelId: string, messageId: string): Promise { return getMessageAPI(this, channelId, messageId); } - async listMessageReactionUsers(channelId: string, messageId: string, emoji: string): Promise { + async listMessageReactionUsers(channelId: string, messageId: string, emoji: string): Promise { return listMessageReactionUsersAPI(this, channelId, messageId, emoji); } } diff --git a/community/src/lib/community/discord/message.test.ts b/community/src/lib/community/discord/message.test.ts index 4e5aa9a..0a8cd30 100644 --- a/community/src/lib/community/discord/message.test.ts +++ b/community/src/lib/community/discord/message.test.ts @@ -119,7 +119,7 @@ test('listMessageReactionUsersAPI fetches and parses users who reacted to a mess assert.deepEqual(users, [{ id: '623456789012345678', username: 'taro', - globalName: 'Taro', + displayName: 'Taro', bot: false, }]); }); diff --git a/community/src/lib/community/discord/message.ts b/community/src/lib/community/discord/message.ts index d1c6f5a..d831c19 100644 --- a/community/src/lib/community/discord/message.ts +++ b/community/src/lib/community/discord/message.ts @@ -1,29 +1,32 @@ import type { DiscordProvider } from './main'; import { CommunityProviderError } from '../error'; +import type { + CommunityMessage, + CommunityReactionUser, + SendMessageInput, + SendMessageResult +} from '../type'; +import { SendMessageResultSchema } from '../type'; import { - DiscordMessage, + DISCORD_MESSAGE_CONTENT_LIMIT, DiscordMessageSchema, - DiscordReactionUser, DiscordReactionUserSchema, - SendMessageInput, - SendMessageInputSchema, - SendMessageResult, - SendMessageResultSchema -} from '../type'; + DiscordSendMessageInputSchema +} from './schema'; // チャンネルへメッセージを送信し、送信したメッセージIDを返す export async function sendMessageAPI( provider: DiscordProvider, input: SendMessageInput ): Promise { - const parsedInput = SendMessageInputSchema.parse(input); + const parsedInput = DiscordSendMessageInputSchema.parse(input); const roleIds = parsedInput.mentionRoleIds ?? []; // メンション対象のロールをメッセージ本文に埋め込む(<@&ロールID>) const mentions = roleIds.map((id) => `<@&${id}>`).join(' '); const content = mentions ? `${mentions} ${parsedInput.content}` : parsedInput.content; - if (content.length > 2000) { + if (content.length > DISCORD_MESSAGE_CONTENT_LIMIT) { throw new CommunityProviderError('Discord message content is too long', 400, 'discord'); } @@ -46,7 +49,7 @@ export async function getMessageAPI( provider: DiscordProvider, channelId: string, messageId: string -): Promise { +): Promise { const message = await provider.request( 'GET', `/channels/${channelId}/messages/${messageId}` @@ -60,7 +63,7 @@ export async function getMessageAPI( author: { id: message.author.id, username: message.author.username, - globalName: message.author.global_name ?? null, + displayName: message.author.global_name ?? null, bot: message.author.bot ?? false, }, reactions: (message.reactions ?? []).map((reaction: any) => ({ @@ -75,8 +78,8 @@ export async function listMessageReactionUsersAPI( channelId: string, messageId: string, emoji: string -): Promise { - const users: DiscordReactionUser[] = []; +): Promise { + const users: CommunityReactionUser[] = []; let after: string | undefined; while (true) { @@ -92,7 +95,7 @@ export async function listMessageReactionUsersAPI( DiscordReactionUserSchema.parse({ id: user.id, username: user.username, - globalName: user.global_name ?? null, + displayName: user.global_name ?? null, bot: user.bot ?? false, }) ); diff --git a/community/src/lib/community/discord/oauth.test.ts b/community/src/lib/community/discord/oauth.test.ts index 28070ab..2a7fe75 100644 --- a/community/src/lib/community/discord/oauth.test.ts +++ b/community/src/lib/community/discord/oauth.test.ts @@ -26,7 +26,7 @@ test('getCurrentDiscordUser verifies the bearer token and maps the Discord profi assert.deepEqual(user, { id: '123456789012345678', username: 'club-member', - globalName: 'Club Member', + displayName: 'Club Member', avatarUrl: 'https://cdn.discordapp.com/avatars/123456789012345678/avatar-hash.png', }); }); diff --git a/community/src/lib/community/discord/oauth.ts b/community/src/lib/community/discord/oauth.ts index b920937..d97f81a 100644 --- a/community/src/lib/community/discord/oauth.ts +++ b/community/src/lib/community/discord/oauth.ts @@ -1,5 +1,6 @@ import { CommunityProviderError } from '../error'; -import { DiscordOAuthUser, DiscordOAuthUserSchema } from '../type'; +import type { CommunityAccountProfile } from '../type'; +import { DiscordAccountProfileSchema } from './schema'; const DISCORD_API_BASE = 'https://discord.com/api/v10'; @@ -22,7 +23,7 @@ const buildDiscordAvatarUrl = (profile: DiscordOAuthProfile): string | null => { export const getCurrentDiscordUser = async ( accessToken: string, fetcher: typeof fetch = fetch, -): Promise => { +): Promise => { const response = await fetcher(`${DISCORD_API_BASE}/users/@me`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -41,10 +42,10 @@ export const getCurrentDiscordUser = async ( } const profile = await response.json(); - const parsed = DiscordOAuthUserSchema.safeParse({ + const parsed = DiscordAccountProfileSchema.safeParse({ id: profile.id, username: profile.username, - globalName: profile.global_name ?? null, + displayName: profile.global_name ?? null, avatarUrl: buildDiscordAvatarUrl(profile), }); diff --git a/community/src/lib/community/discord/role.ts b/community/src/lib/community/discord/role.ts index 27894a5..acfbcde 100644 --- a/community/src/lib/community/discord/role.ts +++ b/community/src/lib/community/discord/role.ts @@ -1,11 +1,10 @@ import type { DiscordProvider } from './main'; +import type { CommunityMembership, CommunityRole } from '../type'; import { - DiscordGuildMembership, - DiscordGuildMembershipSchema, + DiscordMembershipSchema, + DiscordRoleSchema, DiscordSnowflakeSchema, - Role, - RoleSchema, -} from '../type'; +} from './schema'; import { CommunityProviderError } from '../error'; type DiscordGuildMemberResponse = { @@ -22,7 +21,7 @@ type DiscordRoleResponse = { export async function getGuildMembershipAPI( provider: DiscordProvider, userId: string, -): Promise { +): Promise { const expectedUserId = DiscordSnowflakeSchema.parse(userId); const member = await provider.request( 'GET', @@ -45,12 +44,12 @@ export async function getGuildMembershipAPI( ); const roleMap = new Map( allRoles.map((role) => { - const parsed = RoleSchema.parse(role); + const parsed = DiscordRoleSchema.parse(role); return [parsed.id, parsed] as const; }), ); - return DiscordGuildMembershipSchema.parse({ + return DiscordMembershipSchema.parse({ userId: expectedUserId, nickname: member.nick ?? null, roles: memberRoleIds.flatMap((roleId) => { @@ -60,6 +59,6 @@ export async function getGuildMembershipAPI( }); } -export async function listUserRolesAPI(provider: DiscordProvider, userId: string): Promise { +export async function listUserRolesAPI(provider: DiscordProvider, userId: string): Promise { return (await getGuildMembershipAPI(provider, userId)).roles; } diff --git a/community/src/lib/community/discord/schema.ts b/community/src/lib/community/discord/schema.ts new file mode 100644 index 0000000..95da60c --- /dev/null +++ b/community/src/lib/community/discord/schema.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { + CommunityAccountProfileSchema, + CommunityMembershipSchema, + CommunityMessageAuthorSchema, + CommunityMessageSchema, + CommunityReactionUserSchema, + CommunityRoleSchema, + SendMessageInputSchema, +} from '../type'; + +// Discord refinements of the neutral port types. Only this adapter knows that +// Discord identifiers are snowflakes, so the format is asserted here rather +// than in the port every other provider would also have to satisfy. +export const DiscordSnowflakeSchema = z.string().regex(/^\d{17,20}$/); + +export const DiscordRoleSchema = CommunityRoleSchema.extend({ + id: DiscordSnowflakeSchema, +}); + +export const DiscordAccountProfileSchema = CommunityAccountProfileSchema.extend({ + id: DiscordSnowflakeSchema, +}); + +export const DiscordMembershipSchema = CommunityMembershipSchema.extend({ + userId: DiscordSnowflakeSchema, + roles: z.array(DiscordRoleSchema), +}); + +export const DiscordSendMessageInputSchema = SendMessageInputSchema.extend({ + channelId: DiscordSnowflakeSchema, + mentionRoleIds: z.array(DiscordSnowflakeSchema).max(100).optional(), +}); + +export const DiscordMessageAuthorSchema = CommunityMessageAuthorSchema.extend({ + id: DiscordSnowflakeSchema, +}); + +export const DiscordMessageSchema = CommunityMessageSchema.extend({ + id: DiscordSnowflakeSchema, + channelId: DiscordSnowflakeSchema, + author: DiscordMessageAuthorSchema, +}); + +export const DiscordReactionUserSchema = CommunityReactionUserSchema.extend({ + id: DiscordSnowflakeSchema, +}); + +// Discord rejects a message body longer than this once mentions are prepended. +export const DISCORD_MESSAGE_CONTENT_LIMIT = 2000; diff --git a/community/src/lib/community/interface.ts b/community/src/lib/community/interface.ts index ab4c7e5..8e1f0b3 100644 --- a/community/src/lib/community/interface.ts +++ b/community/src/lib/community/interface.ts @@ -1,24 +1,26 @@ -import { z } from 'zod'; import { - DiscordGuildMembership, - DiscordMessage, - DiscordReactionUser, - Role, + CommunityMembership, + CommunityMessage, + CommunityReactionUser, + CommunityRole, SendMessageInput, SendMessageResult } from './type'; // --- Interface Definition --- +// The port every community provider implements. It speaks only the neutral +// types from ./type, so replacing the Discord adapter does not reach into the +// callers that depend on this interface. export interface CommunityProvider { // role - listUserRoles(userId: string): Promise; - getGuildMembership(userId: string): Promise; + listUserRoles(userId: string): Promise; + getGuildMembership(userId: string): Promise; // message sendMessage(input: SendMessageInput): Promise; - getMessage(channelId: string, messageId: string): Promise; - listMessageReactionUsers(channelId: string, messageId: string, emoji: string): Promise; + getMessage(channelId: string, messageId: string): Promise; + listMessageReactionUsers(channelId: string, messageId: string, emoji: string): Promise; } diff --git a/community/src/lib/community/type.ts b/community/src/lib/community/type.ts index 159f68b..e68e2e2 100644 --- a/community/src/lib/community/type.ts +++ b/community/src/lib/community/type.ts @@ -1,28 +1,38 @@ import { z } from 'zod'; -export const DiscordSnowflakeSchema = z.string().regex(/^\d{17,20}$/); +// This module is the community port's vocabulary. It must stay free of any one +// provider's shapes so a provider can be replaced without touching callers. +// Provider-specific formats and limits belong in that provider's adapter — see +// discord/schema.ts for the Discord refinements of these types. + +// Identifiers are opaque here. Discord issues snowflakes, another provider will +// not, so only the adapter that issues an ID may constrain its format. +export const CommunityIdSchema = z.string().min(1); // --- role schemas --- -export const RoleSchema = z.object({ - id: DiscordSnowflakeSchema, +export const CommunityRoleSchema = z.object({ + id: CommunityIdSchema, name: z.string(), }); -export type Role = z.infer; +export type CommunityRole = z.infer; -export const DiscordOAuthUserSchema = z.object({ - id: DiscordSnowflakeSchema, +// The profile of a linked community account, as returned by the provider's +// account authorization flow. +export const CommunityAccountProfileSchema = z.object({ + id: CommunityIdSchema, username: z.string().min(1), - globalName: z.string().nullable(), + displayName: z.string().nullable(), avatarUrl: z.string().url().nullable(), }); -export type DiscordOAuthUser = z.infer; +export type CommunityAccountProfile = z.infer; -export const DiscordGuildMembershipSchema = z.object({ - userId: DiscordSnowflakeSchema, +// Evidence that an account belongs to a specific community. +export const CommunityMembershipSchema = z.object({ + userId: CommunityIdSchema, nickname: z.string().nullable(), - roles: z.array(RoleSchema), + roles: z.array(CommunityRoleSchema), }); -export type DiscordGuildMembership = z.infer; +export type CommunityMembership = z.infer; export const GetUserRolesInputSchema = z.object({ userId: z.string(), @@ -32,10 +42,12 @@ export type GetUserRolesInput = z.infer; // --- message schemas --- // イベント通知メッセージの送信に使う入出力定義 export const SendMessageInputSchema = z.object({ - channelId: DiscordSnowflakeSchema, - content: z.string().min(1).max(2000), + channelId: CommunityIdSchema, + // Per-provider length limits are enforced by the adapter, which is the only + // layer that knows what its API accepts. + content: z.string().min(1), // メンションするロールID一覧(省略可) - mentionRoleIds: z.array(DiscordSnowflakeSchema).max(100).optional(), + mentionRoleIds: z.array(CommunityIdSchema).max(100).optional(), }); export type SendMessageInput = z.infer; @@ -44,34 +56,34 @@ export const SendMessageResultSchema = z.object({ }); export type SendMessageResult = z.infer; -export const DiscordMessageAuthorSchema = z.object({ - id: DiscordSnowflakeSchema, +export const CommunityMessageAuthorSchema = z.object({ + id: CommunityIdSchema, username: z.string(), - globalName: z.string().nullable(), + displayName: z.string().nullable(), bot: z.boolean(), }); -export type DiscordMessageAuthor = z.infer; +export type CommunityMessageAuthor = z.infer; -export const DiscordMessageReactionSchema = z.object({ +export const CommunityMessageReactionSchema = z.object({ emoji: z.string(), count: z.number(), }); -export type DiscordMessageReaction = z.infer; +export type CommunityMessageReaction = z.infer; -export const DiscordMessageSchema = z.object({ - id: DiscordSnowflakeSchema, - channelId: DiscordSnowflakeSchema, +export const CommunityMessageSchema = z.object({ + id: CommunityIdSchema, + channelId: CommunityIdSchema, content: z.string(), createdAt: z.string(), - author: DiscordMessageAuthorSchema, - reactions: z.array(DiscordMessageReactionSchema), + author: CommunityMessageAuthorSchema, + reactions: z.array(CommunityMessageReactionSchema), }); -export type DiscordMessage = z.infer; +export type CommunityMessage = z.infer; -export const DiscordReactionUserSchema = z.object({ - id: DiscordSnowflakeSchema, +export const CommunityReactionUserSchema = z.object({ + id: CommunityIdSchema, username: z.string(), - globalName: z.string().nullable(), + displayName: z.string().nullable(), bot: z.boolean(), }); -export type DiscordReactionUser = z.infer; +export type CommunityReactionUser = z.infer;