diff --git a/src/formatters/subscription-messages.ts b/src/formatters/subscription-messages.ts new file mode 100644 index 0000000..a84afe7 --- /dev/null +++ b/src/formatters/subscription-messages.ts @@ -0,0 +1,27 @@ +import type { EventType } from "../constants"; + +/** + * Format delivery mode info for subscription messages + */ +export function formatDeliveryInfo( + deliveryMode: "webhook" | "polling", + installUrl?: string +): string { + return deliveryMode === "webhook" + ? "⚔ Real-time webhook delivery enabled!" + : `ā±ļø Events checked every 5 minutes\n\nšŸ’” [Install the GitHub App](${installUrl}) for real-time delivery`; +} + +/** + * Format subscription success message + */ +export function formatSubscriptionSuccess( + repoFullName: string, + eventTypes: EventType[], + deliveryInfo: string +): string { + return ( + `āœ… **Subscribed to [${repoFullName}](https://github.com/${repoFullName})**\n\n` + + `šŸ“” Event types: **${eventTypes.join(", ")}**\n\n${deliveryInfo}` + ); +} diff --git a/src/handlers/github-subscription-handler.ts b/src/handlers/github-subscription-handler.ts index 8acd1ce..afae0f1 100644 --- a/src/handlers/github-subscription-handler.ts +++ b/src/handlers/github-subscription-handler.ts @@ -13,7 +13,7 @@ import { } from "../services/github-oauth-service"; import type { SubscriptionService } from "../services/subscription-service"; import type { SlashCommandEvent } from "../types/bot"; -import { sendEditableOAuthPrompt } from "../utils/oauth-helpers"; +import { handleInvalidOAuthToken } from "../utils/oauth-helpers"; import { stripMarkdown } from "../utils/stripper"; export async function handleGithubSubscription( @@ -29,8 +29,8 @@ export async function handleGithubSubscription( await handler.sendMessage( channelId, "**Usage:**\n\n" + - `- \`/github subscribe owner/repo [--events all,${ALLOWED_EVENT_TYPES.join(",")}]\` - Subscribe to GitHub events\n\n` + - "- `/github unsubscribe owner/repo` - Unsubscribe from a repository\n\n" + + `- \`/github subscribe owner/repo [--events all,${ALLOWED_EVENT_TYPES.join(",")}]\` - Subscribe to GitHub events or add event types\n\n` + + "- `/github unsubscribe owner/repo [--events type1,type2]` - Unsubscribe from a repository or remove specific event types\n\n" + "- `/github status` - Show current subscriptions" ); return; @@ -47,7 +47,13 @@ export async function handleGithubSubscription( ); break; case "unsubscribe": - await handleUnsubscribe(handler, event, subscriptionService, repoArg); + await handleUnsubscribe( + handler, + event, + subscriptionService, + oauthService, + repoArg + ); break; case "status": await handleStatus(handler, event, subscriptionService); @@ -74,7 +80,7 @@ async function handleSubscribe( oauthService: GitHubOAuthService, repoArg: string | undefined ): Promise { - const { channelId, spaceId, userId, args } = event; + const { channelId, spaceId, args } = event; if (!repoArg) { await handler.sendMessage( @@ -84,11 +90,8 @@ async function handleSubscribe( return; } - // Strip markdown formatting from repo name - const repo = stripMarkdown(repoArg); - - // Validate repo format - if (!repo.includes("/") || repo.split("/").length !== 2) { + const repo = parseRepoArg(repoArg); + if (!repo) { await handler.sendMessage( channelId, "āŒ Invalid format. Use: `owner/repo` (e.g., `facebook/react`)" @@ -96,6 +99,8 @@ async function handleSubscribe( return; } + const hasEventsFlag = args.some(arg => arg.startsWith("--events")); + // Parse and validate event types from args let eventTypes: EventType[]; try { @@ -107,69 +112,74 @@ async function handleSubscribe( return; } + // Check if already subscribed - if so, add event types instead (case-insensitive match) + const channelSubscriptions = + await subscriptionService.getChannelSubscriptions(channelId, spaceId); + const existingSubscription = channelSubscriptions.find( + sub => sub.repo.toLowerCase() === repo.toLowerCase() + ); + + if (existingSubscription && hasEventsFlag) { + return handleUpdateSubscription( + handler, + event, + subscriptionService, + oauthService, + existingSubscription, + eventTypes + ); + } + + if (existingSubscription) { + await handler.sendMessage( + channelId, + `āŒ Already subscribed to **${existingSubscription.repo}**\n\n` + + "Use `--events` to add specific event types, or `/github status` to view current settings." + ); + return; + } + + return handleNewSubscription( + handler, + event, + subscriptionService, + oauthService, + repo, + eventTypes + ); +} + +/** + * Handle new subscription (no existing subscription for this repo) + */ +async function handleNewSubscription( + handler: BotHandler, + event: SlashCommandEvent, + subscriptionService: SubscriptionService, + oauthService: GitHubOAuthService, + repo: string, + eventTypes: EventType[] +): Promise { + const { channelId, spaceId, userId } = event; + // Check if user has linked their GitHub account and token is valid const tokenStatus = await oauthService.validateToken(userId); if (tokenStatus !== TokenStatus.Valid) { - switch (tokenStatus) { - case TokenStatus.NotLinked: - // Use two-phase pattern for editable OAuth prompts - await sendEditableOAuthPrompt( - oauthService, - handler, - userId, - channelId, - spaceId, - `šŸ” **GitHub Account Required**\n\n` + - `To subscribe to repositories, you need to connect your GitHub account.\n\n` + - `[Connect GitHub Account]({authUrl})`, - "subscribe", - { repo, eventTypes } - ); - return; - - case TokenStatus.Invalid: - await sendEditableOAuthPrompt( - oauthService, - handler, - userId, - channelId, - spaceId, - `āš ļø **GitHub Token Expired**\n\n` + - `Your GitHub token has expired or been revoked. Please reconnect your account.\n\n` + - `[Reconnect GitHub Account]({authUrl})`, - "subscribe", - { repo, eventTypes } - ); - return; - - case TokenStatus.Unknown: { - // Generate auth URL for Unknown status (not using two-phase pattern as this is less common) - const authUrl = await oauthService.getAuthorizationUrl( - userId, - channelId, - spaceId, - "subscribe", - { repo, eventTypes } - ); - await handler.sendMessage( - channelId, - `āš ļø **Unable to Verify GitHub Connection**\n\n` + - `We couldn't verify your GitHub token. This could be temporary (rate limiting) or indicate a connection issue.\n\n` + - `Please try again in a few moments, or [reconnect your account](${authUrl}) if the problem persists.` - ); - return; - } - - default: { - // TypeScript exhaustiveness check - const _exhaustive: never = tokenStatus; - return _exhaustive; - } - } + await handleInvalidOAuthToken( + tokenStatus, + oauthService, + handler, + userId, + channelId, + spaceId, + "subscribe", + { repo, eventTypes } + ); + return; } - // Create subscription (OAuth check already done) + // Create subscription const result = await subscriptionService.createSubscription({ townsUserId: userId, spaceId, @@ -178,7 +188,6 @@ async function handleSubscribe( eventTypes, }); - // Handle installation requirement (private repos) if (!result.success && result.requiresInstallation) { await handler.sendMessage( channelId, @@ -190,25 +199,69 @@ async function handleSubscribe( return; } - // Handle other errors if (!result.success) { await handler.sendMessage(channelId, `āŒ ${result.error}`); return; } - // Success - format response - const eventTypeDisplay = formatEventTypes(eventTypes); - const deliveryInfo = - result.deliveryMode === "webhook" - ? "⚔ Real-time webhook delivery enabled!" - : "ā±ļø Events are checked every 5 minutes (polling mode)\n\n" + - `šŸ’” **Want real-time notifications?** [Install the GitHub App](${result.installUrl})`; + await subscriptionService.sendSubscriptionSuccess( + result, + eventTypes, + channelId, + handler + ); +} + +/** + * Handle update to existing subscription (add event types) + */ +async function handleUpdateSubscription( + handler: BotHandler, + event: SlashCommandEvent, + subscriptionService: SubscriptionService, + oauthService: GitHubOAuthService, + existingSubscription: { repo: string; deliveryMode: string }, + eventTypes: EventType[] +): Promise { + const { channelId, spaceId, userId } = event; + const { repo } = existingSubscription; + + // Check if user has linked their GitHub account and token is valid + const tokenStatus = await oauthService.validateToken(userId); + + if (tokenStatus !== TokenStatus.Valid) { + await handleInvalidOAuthToken( + tokenStatus, + oauthService, + handler, + userId, + channelId, + spaceId, + "subscribe-update", + { repo, eventTypes } + ); + return; + } + + // Add event types to existing subscription (validates repo access) + const addResult = await subscriptionService.addEventTypes( + userId, + spaceId, + channelId, + repo, + eventTypes + ); + + if (!addResult.success) { + await handler.sendMessage(channelId, `āŒ ${addResult.error}`); + return; + } + const mode = existingSubscription.deliveryMode === "webhook" ? "⚔" : "ā±ļø"; await handler.sendMessage( channelId, - `āœ… **Subscribed to [${result.repoFullName}](https://github.com/${result.repoFullName})**\n\n` + - `šŸ“” Event types: **${eventTypeDisplay}**\n\n` + - `${deliveryInfo}` + `āœ… **Updated subscription to ${repo}**\n\n` + + `${mode} Event types: **${formatEventTypes(addResult.eventTypes!)}**` ); } @@ -219,23 +272,21 @@ async function handleUnsubscribe( handler: BotHandler, event: SlashCommandEvent, subscriptionService: SubscriptionService, + oauthService: GitHubOAuthService, repoArg: string | undefined ): Promise { - const { channelId, spaceId } = event; + const { channelId, spaceId, args } = event; if (!repoArg) { await handler.sendMessage( channelId, - "āŒ Usage: `/github unsubscribe owner/repo`" + "āŒ Usage: `/github unsubscribe owner/repo [--events type1,type2]`" ); return; } - // Strip markdown formatting from repo name - const repo = stripMarkdown(repoArg); - - // Validate repo format - if (!repo.includes("/") || repo.split("/").length !== 2) { + const repo = parseRepoArg(repoArg); + if (!repo) { await handler.sendMessage( channelId, "āŒ Invalid format. Use: `owner/repo` (e.g., `facebook/react`)" @@ -269,21 +320,184 @@ async function handleUnsubscribe( return; } - // Remove subscription using canonical repo name from the DB - const success = await subscriptionService.unsubscribe( - channelId, + // Check for --events flag for granular unsubscribe + const eventsIndex = args.findIndex(arg => arg.startsWith("--events")); + + if (eventsIndex !== -1) { + return handleRemoveEventTypes( + handler, + event, + subscriptionService, + oauthService, + subscription.repo, + subscription.eventTypes, + eventsIndex + ); + } + + // Full unsubscribe + return handleFullUnsubscribe( + handler, + event, + subscriptionService, + oauthService, + subscription.repo, + subscription.eventTypes + ); +} + +/** + * Handle removing specific event types from a subscription + */ +async function handleRemoveEventTypes( + handler: BotHandler, + event: SlashCommandEvent, + subscriptionService: SubscriptionService, + oauthService: GitHubOAuthService, + repo: string, + eventTypes: EventType[], + eventsIndex: number +): Promise { + const { channelId, spaceId, userId, args } = event; + + // Parse event types + let eventTypesToRemove = ""; + if (args[eventsIndex].includes("=")) { + eventTypesToRemove = args[eventsIndex].split("=")[1] || ""; + } else if (eventsIndex + 1 < args.length) { + eventTypesToRemove = args[eventsIndex + 1]; + } + + const typesToRemove = eventTypesToRemove + .split(",") + .map(t => t.trim().toLowerCase()) + .filter(t => t.length > 0); + + if (typesToRemove.length === 0) { + await handler.sendMessage( + channelId, + "āŒ Please specify event types to remove: `--events pr,issues`" + ); + return; + } + + // Validate event types + const allowedSet = new Set(ALLOWED_EVENT_TYPES); + const invalidTypes = typesToRemove.filter( + t => !allowedSet.has(t as (typeof ALLOWED_EVENT_TYPES)[number]) + ); + if (invalidTypes.length > 0) { + await handler.sendMessage( + channelId, + `āŒ Invalid event type(s): ${invalidTypes.join(", ")}\n\n` + + `Valid options: ${ALLOWED_EVENT_TYPES.join(", ")}` + ); + return; + } + + // Check if user has linked their GitHub account and token is valid + const tokenStatus = await oauthService.validateToken(userId); + + if (tokenStatus !== TokenStatus.Valid) { + await handleInvalidOAuthToken( + tokenStatus, + oauthService, + handler, + userId, + channelId, + spaceId, + "unsubscribe-update", + { repo, eventTypes: typesToRemove as EventType[] } + ); + return; + } + + // Compute actually removed types (intersection with current subscription) + const actuallyRemoved = eventTypes.filter(t => + (typesToRemove as EventType[]).includes(t) + ); + + // Remove event types (validates repo access) + const removeResult = await subscriptionService.removeEventTypes( + userId, spaceId, - subscription.repo + channelId, + repo, + typesToRemove as EventType[] ); - if (success) { - await handler.sendMessage(channelId, `āœ… **Unsubscribed from ${repo}**`); + if (!removeResult.success) { + await handler.sendMessage(channelId, `āŒ ${removeResult.error}`); + return; + } + + if (removeResult.deleted) { + await handler.sendMessage( + channelId, + `āœ… **Unsubscribed from ${repo}**\n\n` + `All event types were removed.` + ); } else { + const removedLabel = + actuallyRemoved.length > 0 ? actuallyRemoved.join(", ") : "(none)"; + const header = + actuallyRemoved.length > 0 + ? `āœ… **Updated subscription to ${repo}**\n\n` + : `ā„¹ļø **Subscription to ${repo} unchanged**\n\n`; + await handler.sendMessage( channelId, - `āŒ Failed to unsubscribe from **${repo}**` + header + + `Removed: **${removedLabel}**\n` + + `Remaining: **${formatEventTypes(removeResult.eventTypes!)}**` + ); + } +} + +/** + * Handle full unsubscribe from a repository + */ +async function handleFullUnsubscribe( + handler: BotHandler, + event: SlashCommandEvent, + subscriptionService: SubscriptionService, + oauthService: GitHubOAuthService, + repo: string, + eventTypes: EventType[] +): Promise { + const { channelId, spaceId, userId } = event; + + // Check if user has linked their GitHub account and token is valid + const tokenStatus = await oauthService.validateToken(userId); + + if (tokenStatus !== TokenStatus.Valid) { + await handleInvalidOAuthToken( + tokenStatus, + oauthService, + handler, + userId, + channelId, + spaceId, + "unsubscribe-update", + { repo, eventTypes } ); + return; + } + + // Use removeEventTypes with all event types - validates repo access and deletes subscription + const removeResult = await subscriptionService.removeEventTypes( + userId, + spaceId, + channelId, + repo, + eventTypes + ); + + if (!removeResult.success) { + await handler.sendMessage(channelId, `āŒ ${removeResult.error}`); + return; } + + await handler.sendMessage(channelId, `āœ… **Unsubscribed from ${repo}**`); } /** @@ -322,6 +536,18 @@ async function handleStatus( ); } +/** + * Parse and validate repo argument (strips markdown, validates owner/repo format) + * Returns null if invalid format + */ +function parseRepoArg(repoArg: string): string | null { + const repo = stripMarkdown(repoArg); + if (!repo.includes("/") || repo.split("/").length !== 2) { + return null; + } + return repo; +} + /** * Parse and validate event types from --events flag * Returns default types if no flag, or validated event types array diff --git a/src/routes/oauth-callback.ts b/src/routes/oauth-callback.ts index 81dcc25..d5c5e11 100644 --- a/src/routes/oauth-callback.ts +++ b/src/routes/oauth-callback.ts @@ -1,7 +1,7 @@ import type { Context } from "hono"; import { getOwnerIdFromUsername, parseRepo } from "../api/github-client"; -import { DEFAULT_EVENT_TYPES_ARRAY } from "../constants"; +import { DEFAULT_EVENT_TYPES_ARRAY, type EventType } from "../constants"; import { generateInstallUrl, type InstallationService, @@ -62,36 +62,27 @@ export async function handleOAuthCallback( // If there was a redirect action (e.g., subscribe), complete the subscription if (redirectAction === "subscribe" && redirectData) { if (redirectData.repo && spaceId && townsUserId) { + const eventTypes: EventType[] = redirectData.eventTypes ?? [ + ...DEFAULT_EVENT_TYPES_ARRAY, + ]; + // Attempt subscription now that OAuth is complete const subResult = await subscriptionService.createSubscription({ townsUserId, spaceId, channelId, repoIdentifier: redirectData.repo, - eventTypes: redirectData.eventTypes ?? [...DEFAULT_EVENT_TYPES_ARRAY], + eventTypes, }); if (subResult.success) { - // Success - notify in Towns - const deliveryInfo = - subResult.deliveryMode === "webhook" - ? "⚔ Real-time webhook delivery enabled!" - : `ā±ļø Events checked every 5 minutes\n\nšŸ’” [Install the GitHub App](<${subResult.installUrl}>) for real-time delivery`; - - const { eventId } = await bot.sendMessage( + await subscriptionService.sendSubscriptionSuccess( + subResult, + eventTypes, channelId, - `āœ… **Subscribed to [${subResult.repoFullName}](https://github.com/${subResult.repoFullName})**\n\n${deliveryInfo}` + bot ); - // Track polling messages for potential upgrade to webhook - if (subResult.deliveryMode === "polling" && eventId) { - subscriptionService.registerPendingMessage( - channelId, - subResult.repoFullName, - eventId - ); - } - // Return success page with subscription data return renderSuccess(c, { action: "subscribe", @@ -115,6 +106,73 @@ export async function handleOAuthCallback( } } + // Handle subscription update (add event types to existing subscription) + if (redirectAction === "subscribe-update" && redirectData) { + if (redirectData.repo && spaceId && townsUserId) { + const eventTypes: EventType[] = redirectData.eventTypes ?? [ + ...DEFAULT_EVENT_TYPES_ARRAY, + ]; + + const updateResult = await subscriptionService.addEventTypes( + townsUserId, + spaceId, + channelId, + redirectData.repo, + eventTypes + ); + + if (updateResult.success) { + await bot.sendMessage( + channelId, + `āœ… **Updated subscription to ${redirectData.repo}**\n\n` + + `Event types: **${updateResult.eventTypes!.join(", ")}**` + ); + } else { + await bot.sendMessage(channelId, `āŒ ${updateResult.error}`); + } + + // Show basic success page (user should return to Towns) + return renderSuccess(c); + } + } + + // Handle unsubscribe update (remove event types from existing subscription) + if (redirectAction === "unsubscribe-update" && redirectData) { + if ( + redirectData.repo && + spaceId && + townsUserId && + redirectData.eventTypes + ) { + const removeResult = await subscriptionService.removeEventTypes( + townsUserId, + spaceId, + channelId, + redirectData.repo, + redirectData.eventTypes + ); + + if (removeResult.success) { + if (removeResult.deleted) { + await bot.sendMessage( + channelId, + `āœ… **Unsubscribed from ${redirectData.repo}**` + ); + } else { + await bot.sendMessage( + channelId, + `āœ… **Updated subscription to ${redirectData.repo}**\n\n` + + `Remaining event types: **${removeResult.eventTypes!.join(", ")}**` + ); + } + } else { + await bot.sendMessage(channelId, `āŒ ${removeResult.error}`); + } + + return renderSuccess(c); + } + } + // Handle query command redirect (gh_pr, gh_issue) // Check if app installation is needed for the repo if (redirectAction === "query" && redirectData?.repo) { diff --git a/src/services/subscription-service.ts b/src/services/subscription-service.ts index 4144615..4caf2b9 100644 --- a/src/services/subscription-service.ts +++ b/src/services/subscription-service.ts @@ -1,4 +1,5 @@ import { and, eq, inArray, sql } from "drizzle-orm"; +import type { BotHandler } from "@towns-protocol/bot"; import { getOwnerIdFromUsername } from "../api/github-client"; import { @@ -8,7 +9,8 @@ import { type UserProfile, } from "../api/user-oauth-client"; import { - DEFAULT_EVENT_TYPES, + ALLOWED_EVENT_TYPES_SET, + DEFAULT_EVENT_TYPES_ARRAY, PENDING_MESSAGE_CLEANUP_INTERVAL_MS, PENDING_MESSAGE_MAX_AGE_MS, PENDING_SUBSCRIPTION_CLEANUP_INTERVAL_MS, @@ -17,6 +19,10 @@ import { } from "../constants"; import { db } from "../db"; import { githubSubscriptions, pendingSubscriptions } from "../db/schema"; +import { + formatDeliveryInfo, + formatSubscriptionSuccess, +} from "../formatters/subscription-messages"; import { generateInstallUrl, InstallationService, @@ -107,15 +113,8 @@ export class SubscriptionService { const { townsUserId, spaceId, channelId, repoIdentifier, eventTypes } = params; - // Parse owner/repo + // Parse owner/repo (caller validates format) const [owner, repo] = repoIdentifier.split("/"); - if (!owner || !repo) { - return { - success: false, - requiresInstallation: false, - error: `Invalid repository format. Use: owner/repo`, - }; - } // 1. Get OAuth token (assumes caller has checked OAuth is linked) const githubToken = await this.oauthService.getToken(townsUserId); @@ -269,66 +268,141 @@ export class SubscriptionService { } /** - * Store pending subscription and return failure requiring installation + * Add event types to an existing subscription + * Validates repo access using the user's OAuth token + * Returns the updated event types array */ - private async requiresInstallationFailure(params: { - townsUserId: string; - spaceId: string; - channelId: string; - repoFullName: string; - eventTypes: EventType[]; - ownerId?: number; - }): Promise { - await this.storePendingSubscription({ - townsUserId: params.townsUserId, - spaceId: params.spaceId, - channelId: params.channelId, - repoFullName: params.repoFullName, - eventTypes: params.eventTypes, - }); + async addEventTypes( + townsUserId: string, + spaceId: string, + channelId: string, + repoFullName: string, + newEventTypes: EventType[] + ): Promise<{ success: boolean; eventTypes?: EventType[]; error?: string }> { + const validation = await this.validateRepoAccessAndGetSubscription( + townsUserId, + spaceId, + channelId, + repoFullName + ); + if (!validation.success) return validation; + const { eventTypes: currentTypes } = validation; + + if (newEventTypes.length === 0) { + return { success: true, eventTypes: currentTypes }; + } + + // Merge and deduplicate + const mergedTypes = [ + ...new Set([...currentTypes, ...newEventTypes]), + ] as EventType[]; + + // Update subscription + const result = await db + .update(githubSubscriptions) + .set({ + eventTypes: mergedTypes.join(","), + updatedAt: new Date(), + }) + .where( + and( + eq(githubSubscriptions.spaceId, spaceId), + eq(githubSubscriptions.channelId, channelId), + eq(githubSubscriptions.repoFullName, repoFullName) + ) + ) + .returning({ id: githubSubscriptions.id }); + + if (result.length === 0) { + return { + success: false, + error: `Subscription no longer exists for ${repoFullName}`, + }; + } return { - success: false, - requiresInstallation: true, - installUrl: generateInstallUrl(params.ownerId), - repoFullName: params.repoFullName, - eventTypes: params.eventTypes, - error: "Private repository requires GitHub App installation", + success: true, + eventTypes: mergedTypes, }; } /** - * Store a pending subscription for completion after GitHub App installation - * Used when user tries to subscribe to private repo before installing GitHub App + * Remove event types from an existing subscription + * Validates repo access using the user's OAuth token + * If all event types are removed, deletes the subscription entirely + * Returns whether the subscription still exists and the updated event types */ - private async storePendingSubscription(params: { - townsUserId: string; - spaceId: string; - channelId: string; - repoFullName: string; - eventTypes: EventType[]; - }): Promise { - const now = new Date(); - const expiresAt = new Date( - now.getTime() + PENDING_SUBSCRIPTION_EXPIRATION_MS + async removeEventTypes( + townsUserId: string, + spaceId: string, + channelId: string, + repoFullName: string, + typesToRemove: EventType[] + ): Promise<{ + success: boolean; + deleted?: boolean; + eventTypes?: EventType[]; + error?: string; + }> { + const validation = await this.validateRepoAccessAndGetSubscription( + townsUserId, + spaceId, + channelId, + repoFullName ); + if (!validation.success) return validation; + const { eventTypes: currentTypes } = validation; - await db - .insert(pendingSubscriptions) - .values({ - townsUserId: params.townsUserId, - spaceId: params.spaceId, - channelId: params.channelId, - repoFullName: params.repoFullName, - eventTypes: params.eventTypes.join(","), - createdAt: now, - expiresAt, + if (typesToRemove.length === 0) { + return { success: true, deleted: false, eventTypes: currentTypes }; + } + + // Remove specified types + const remainingTypes = currentTypes.filter(t => !typesToRemove.includes(t)); + + // If no types remain, delete the subscription + if (remainingTypes.length === 0) { + const deleted = await this.unsubscribe(channelId, spaceId, repoFullName); + if (!deleted) { + return { + success: false, + error: `Failed to delete subscription for ${repoFullName}`, + }; + } + return { + success: true, + deleted: true, + }; + } + + // Update subscription with remaining types + const updated = await db + .update(githubSubscriptions) + .set({ + eventTypes: remainingTypes.join(","), + updatedAt: new Date(), }) - .onConflictDoNothing(); + .where( + and( + eq(githubSubscriptions.spaceId, spaceId), + eq(githubSubscriptions.channelId, channelId), + eq(githubSubscriptions.repoFullName, repoFullName) + ) + ) + .returning({ id: githubSubscriptions.id }); - console.log( - `[Subscribe] Stored pending subscription for ${params.repoFullName}` - ); + if (updated.length === 0) { + return { + success: false, + error: `Subscription no longer exists for ${repoFullName}`, + }; + } + + return { + success: true, + deleted: false, + eventTypes: remainingTypes, + }; } /** @@ -369,7 +443,7 @@ export class SubscriptionService { spaceId: sub.spaceId, channelId: sub.channelId, repoIdentifier: repoFullName, - eventTypes: sub.eventTypes.split(",") as EventType[], + eventTypes: parseEventTypes(sub.eventTypes), }); if (result.success && this.bot) { @@ -421,6 +495,44 @@ export class SubscriptionService { return result.length > 0; } + /** + * Get a specific subscription + */ + async getSubscription( + spaceId: string, + channelId: string, + repoFullName: string + ): Promise<{ + id: number; + eventTypes: EventType[]; + deliveryMode: string; + createdByTownsUserId: string; + } | null> { + const results = await db + .select({ + id: githubSubscriptions.id, + eventTypes: githubSubscriptions.eventTypes, + deliveryMode: githubSubscriptions.deliveryMode, + createdByTownsUserId: githubSubscriptions.createdByTownsUserId, + }) + .from(githubSubscriptions) + .where( + and( + eq(githubSubscriptions.spaceId, spaceId), + eq(githubSubscriptions.channelId, channelId), + eq(githubSubscriptions.repoFullName, repoFullName) + ) + ) + .limit(1); + + if (!results[0]) return null; + + return { + ...results[0], + eventTypes: parseEventTypes(results[0].eventTypes), + }; + } + /** * Get all subscriptions for a channel */ @@ -446,9 +558,7 @@ export class SubscriptionService { return results.map(r => ({ repo: r.repo, - eventTypes: (r.eventTypes || DEFAULT_EVENT_TYPES).split( - "," - ) as EventType[], + eventTypes: parseEventTypes(r.eventTypes), deliveryMode: r.deliveryMode, })); } @@ -481,9 +591,7 @@ export class SubscriptionService { return results.map(r => ({ channelId: r.channelId, - eventTypes: (r.eventTypes || DEFAULT_EVENT_TYPES).split( - "," - ) as EventType[], + eventTypes: parseEventTypes(r.eventTypes), })); } @@ -695,10 +803,35 @@ export class SubscriptionService { return result.length; } + /** + * Send subscription success message and register pending message for polling mode + */ + async sendSubscriptionSuccess( + result: Extract, + eventTypes: EventType[], + channelId: string, + sender: Pick + ): Promise { + const installUrl = + result.deliveryMode === "polling" ? result.installUrl : undefined; + const deliveryInfo = formatDeliveryInfo(result.deliveryMode, installUrl); + const message = formatSubscriptionSuccess( + result.repoFullName, + eventTypes, + deliveryInfo + ); + + const { eventId } = await sender.sendMessage(channelId, message); + + if (result.deliveryMode === "polling" && eventId) { + this.registerPendingMessage(channelId, result.repoFullName, eventId); + } + } + /** * Register a pending subscription message for later updates */ - registerPendingMessage( + private registerPendingMessage( channelId: string, repoFullName: string, eventId: string @@ -748,4 +881,118 @@ export class SubscriptionService { ); } } + + /** + * Store pending subscription and return failure requiring installation + */ + private async requiresInstallationFailure(params: { + townsUserId: string; + spaceId: string; + channelId: string; + repoFullName: string; + eventTypes: EventType[]; + ownerId?: number; + }): Promise { + await this.storePendingSubscription({ + townsUserId: params.townsUserId, + spaceId: params.spaceId, + channelId: params.channelId, + repoFullName: params.repoFullName, + eventTypes: params.eventTypes, + }); + + return { + success: false, + requiresInstallation: true, + installUrl: generateInstallUrl(params.ownerId), + repoFullName: params.repoFullName, + eventTypes: params.eventTypes, + error: "Private repository requires GitHub App installation", + }; + } + + /** + * Store a pending subscription for completion after GitHub App installation + * Used when user tries to subscribe to private repo before installing GitHub App + */ + private async storePendingSubscription(params: { + townsUserId: string; + spaceId: string; + channelId: string; + repoFullName: string; + eventTypes: EventType[]; + }): Promise { + const now = new Date(); + const expiresAt = new Date( + now.getTime() + PENDING_SUBSCRIPTION_EXPIRATION_MS + ); + + await db + .insert(pendingSubscriptions) + .values({ + townsUserId: params.townsUserId, + spaceId: params.spaceId, + channelId: params.channelId, + repoFullName: params.repoFullName, + eventTypes: params.eventTypes.join(","), + createdAt: now, + expiresAt, + }) + .onConflictDoNothing(); + + console.log( + `[Subscribe] Stored pending subscription for ${params.repoFullName}` + ); + } + + /** + * Validate repo access and get subscription + * @throws Error if OAuth token not found (programming error) + * @returns subscription on success, or error string on failure + */ + private async validateRepoAccessAndGetSubscription( + townsUserId: string, + spaceId: string, + channelId: string, + repoFullName: string + ): Promise< + | { success: true; eventTypes: EventType[] } + | { success: false; error: string } + > { + const githubToken = await this.oauthService.getToken(townsUserId); + if (!githubToken) { + throw new Error( + "OAuth token not found. Caller should check OAuth status before calling this method." + ); + } + + const [owner, repo] = repoFullName.split("/"); + try { + await validateRepository(githubToken, owner, repo); + } catch { + return { + success: false, + error: "You don't have access to this repository", + }; + } + + const subscription = await this.getSubscription( + spaceId, + channelId, + repoFullName + ); + if (!subscription) { + return { success: false, error: `Not subscribed to ${repoFullName}` }; + } + + return { success: true, eventTypes: subscription.eventTypes }; + } +} + +/** Parse DB event types string to EventType[], filtering invalid values */ +function parseEventTypes(eventTypes: string | null): EventType[] { + if (!eventTypes) return [...DEFAULT_EVENT_TYPES_ARRAY]; + return eventTypes + .split(",") + .filter((e): e is EventType => ALLOWED_EVENT_TYPES_SET.has(e as EventType)); } diff --git a/src/types/oauth.ts b/src/types/oauth.ts index ed25265..d855e85 100644 --- a/src/types/oauth.ts +++ b/src/types/oauth.ts @@ -3,7 +3,12 @@ import { z } from "zod"; import { ALLOWED_EVENT_TYPES } from "../constants"; /** Supported redirect actions after OAuth completion */ -export const RedirectActionSchema = z.enum(["subscribe", "query"]); +export const RedirectActionSchema = z.enum([ + "subscribe", + "subscribe-update", + "unsubscribe-update", + "query", +]); export type RedirectAction = z.infer; /** Event type schema for validation */ diff --git a/src/utils/oauth-helpers.ts b/src/utils/oauth-helpers.ts index 6b4c655..229091d 100644 --- a/src/utils/oauth-helpers.ts +++ b/src/utils/oauth-helpers.ts @@ -1,6 +1,10 @@ import type { BotHandler } from "@towns-protocol/bot"; -import type { GitHubOAuthService } from "../services/github-oauth-service"; +import { EventType } from "../constants"; +import { + GitHubOAuthService, + TokenStatus, +} from "../services/github-oauth-service"; import type { RedirectAction, RedirectData } from "../types/oauth"; /** @@ -104,3 +108,71 @@ export async function sendEditableOAuthPrompt( return null; } } + +/** + * Handle invalid OAuth token status by showing appropriate prompt + */ +export async function handleInvalidOAuthToken( + tokenStatus: Exclude, + oauthService: GitHubOAuthService, + handler: BotHandler, + userId: string, + channelId: string, + spaceId: string, + redirectAction: RedirectAction, + redirectData: { repo: string; eventTypes: EventType[] } +): Promise { + switch (tokenStatus) { + case TokenStatus.NotLinked: + await sendEditableOAuthPrompt( + oauthService, + handler, + userId, + channelId, + spaceId, + `šŸ” **GitHub Account Required**\n\n` + + `To modify subscriptions, you need to connect your GitHub account.\n\n` + + `[Connect GitHub Account]({authUrl})`, + redirectAction, + redirectData + ); + return; + + case TokenStatus.Invalid: + await sendEditableOAuthPrompt( + oauthService, + handler, + userId, + channelId, + spaceId, + `āš ļø **GitHub Token Expired**\n\n` + + `Your GitHub token has expired or been revoked. Please reconnect your account.\n\n` + + `[Reconnect GitHub Account]({authUrl})`, + redirectAction, + redirectData + ); + return; + + case TokenStatus.Unknown: { + const authUrl = await oauthService.getAuthorizationUrl( + userId, + channelId, + spaceId, + redirectAction, + redirectData + ); + await handler.sendMessage( + channelId, + `āš ļø **Unable to Verify GitHub Connection**\n\n` + + `We couldn't verify your GitHub token. This could be temporary (rate limiting) or indicate a connection issue.\n\n` + + `Please try again in a few moments, or [reconnect your account](${authUrl}) if the problem persists.` + ); + return; + } + + default: { + const _exhaustive: never = tokenStatus; + console.error("Unhandled token status:", _exhaustive); + } + } +} diff --git a/tests/unit/handlers/github-subscription-handler.test.ts b/tests/unit/handlers/github-subscription-handler.test.ts index aa9cfc6..7ac2d92 100644 --- a/tests/unit/handlers/github-subscription-handler.test.ts +++ b/tests/unit/handlers/github-subscription-handler.test.ts @@ -45,6 +45,11 @@ describe("github subscription handler", () => { ), getChannelSubscriptions: mock(() => Promise.resolve([])), unsubscribe: mock(() => Promise.resolve(true)), + removeEventTypes: mock(() => + Promise.resolve({ success: true, deleted: true }) + ), + registerPendingMessage: mock(() => {}), + sendSubscriptionSuccess: mock(() => Promise.resolve()), }; // Mock OAuth service @@ -96,9 +101,9 @@ describe("github subscription handler", () => { mockOAuthService ); - expect(mockHandler.sendMessage).toHaveBeenCalledTimes(1); - const message = mockHandler.sendMessage.mock.calls[0][1]; - expect(message).toContain("āœ… **Subscribed"); + expect( + mockSubscriptionService.sendSubscriptionSuccess + ).toHaveBeenCalled(); }); test("should handle case-insensitive actions - unsubscribe", async () => { @@ -201,8 +206,9 @@ describe("github subscription handler", () => { eventTypes: DEFAULT_EVENT_TYPES.split(","), }); - const message = mockHandler.sendMessage.mock.calls[0][1]; - expect(message).toContain("āœ… **Subscribed"); + expect( + mockSubscriptionService.sendSubscriptionSuccess + ).toHaveBeenCalled(); }); test("should handle custom event types with --events flag", async () => { @@ -336,13 +342,15 @@ describe("github subscription handler", () => { }); test("should handle polling mode delivery", async () => { + const pollingResult = { + success: true as const, + repoFullName: "owner/repo", + deliveryMode: "polling" as const, + installUrl: "https://github.com/apps/test/installations/new", + eventTypes: ["pr", "issues"] as const, + }; mockSubscriptionService.createSubscription = mock(() => - Promise.resolve({ - success: true, - repoFullName: "owner/repo", - deliveryMode: "polling", - installUrl: "https://github.com/apps/test/installations/new", - }) + Promise.resolve(pollingResult) ); await handleGithubSubscription( @@ -352,8 +360,15 @@ describe("github subscription handler", () => { mockOAuthService ); - const message = mockHandler.sendMessage.mock.calls[0][1]; - expect(message).toContain("ā±ļø Events are checked every 5 minutes"); + // Verify sendSubscriptionSuccess is called with correct args + expect( + mockSubscriptionService.sendSubscriptionSuccess + ).toHaveBeenCalledWith( + pollingResult, + expect.any(Array), + "test-channel", + mockHandler + ); }); test("should send info message when already subscribed", async () => { @@ -463,11 +478,11 @@ describe("github subscription handler", () => { test("should successfully unsubscribe from repo", async () => { mockSubscriptionService.getChannelSubscriptions = mock(() => - Promise.resolve([ - { repo: "owner/repo", eventTypes: DEFAULT_EVENT_TYPES }, - ]) + Promise.resolve([{ repo: "owner/repo", eventTypes: ["pr", "issues"] }]) + ); + mockSubscriptionService.removeEventTypes = mock(() => + Promise.resolve({ success: true, deleted: true }) ); - mockSubscriptionService.unsubscribe = mock(() => Promise.resolve(true)); await handleGithubSubscription( mockHandler, @@ -476,12 +491,14 @@ describe("github subscription handler", () => { mockOAuthService ); - const unsubCalls = mockSubscriptionService.unsubscribe.mock.calls; - expect(unsubCalls.length).toBe(1); - expect(unsubCalls[0]).toEqual([ - "test-channel", + const removeCalls = mockSubscriptionService.removeEventTypes.mock.calls; + expect(removeCalls.length).toBe(1); + expect(removeCalls[0]).toEqual([ + "0x123", "test-space", + "test-channel", "owner/repo", + ["pr", "issues"], ]); const message = mockHandler.sendMessage.mock.calls[0][1]; @@ -490,11 +507,11 @@ describe("github subscription handler", () => { test("should handle case-insensitive repo names", async () => { mockSubscriptionService.getChannelSubscriptions = mock(() => - Promise.resolve([ - { repo: "owner/repo", eventTypes: DEFAULT_EVENT_TYPES }, - ]) + Promise.resolve([{ repo: "owner/repo", eventTypes: ["pr", "issues"] }]) + ); + mockSubscriptionService.removeEventTypes = mock(() => + Promise.resolve({ success: true, deleted: true }) ); - mockSubscriptionService.unsubscribe = mock(() => Promise.resolve(true)); await handleGithubSubscription( mockHandler, @@ -503,17 +520,17 @@ describe("github subscription handler", () => { mockOAuthService ); - const unsubCalls = mockSubscriptionService.unsubscribe.mock.calls; - expect(unsubCalls[0][2]).toBe("owner/repo"); + const removeCalls = mockSubscriptionService.removeEventTypes.mock.calls; + expect(removeCalls[0][3]).toBe("owner/repo"); }); test("should handle unsubscribe failure", async () => { mockSubscriptionService.getChannelSubscriptions = mock(() => - Promise.resolve([ - { repo: "owner/repo", eventTypes: DEFAULT_EVENT_TYPES }, - ]) + Promise.resolve([{ repo: "owner/repo", eventTypes: ["pr", "issues"] }]) + ); + mockSubscriptionService.removeEventTypes = mock(() => + Promise.resolve({ success: false, error: "You don't have access" }) ); - mockSubscriptionService.unsubscribe = mock(() => Promise.resolve(false)); await handleGithubSubscription( mockHandler, @@ -523,16 +540,16 @@ describe("github subscription handler", () => { ); const message = mockHandler.sendMessage.mock.calls[0][1]; - expect(message).toContain("āŒ Failed to unsubscribe"); + expect(message).toContain("āŒ You don't have access"); }); test("should strip markdown from repo name", async () => { mockSubscriptionService.getChannelSubscriptions = mock(() => - Promise.resolve([ - { repo: "owner/repo", eventTypes: DEFAULT_EVENT_TYPES }, - ]) + Promise.resolve([{ repo: "owner/repo", eventTypes: ["pr", "issues"] }]) + ); + mockSubscriptionService.removeEventTypes = mock(() => + Promise.resolve({ success: true, deleted: true }) ); - mockSubscriptionService.unsubscribe = mock(() => Promise.resolve(true)); await handleGithubSubscription( mockHandler, @@ -541,9 +558,9 @@ describe("github subscription handler", () => { mockOAuthService ); - const unsubCalls = mockSubscriptionService.unsubscribe.mock.calls; - expect(unsubCalls.length).toBe(1); - expect(unsubCalls[0][2]).toBe("owner/repo"); + const removeCalls = mockSubscriptionService.removeEventTypes.mock.calls; + expect(removeCalls.length).toBe(1); + expect(removeCalls[0][3]).toBe("owner/repo"); }); });