Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { ProcessTrackingService } from "../services/process-tracking/service";
import { ProvisioningService } from "../services/provisioning/service";
import { settingsStore } from "../services/settingsStore";
import { ShellService } from "../services/shell/service";
import { SlackIntegrationService } from "../services/slack-integration/service";
import { SleepService } from "../services/sleep/service";
import { SuspensionService } from "../services/suspension/service";
import { TaskLinkService } from "../services/task-link/service";
Expand Down Expand Up @@ -136,6 +137,7 @@ container.bind(MAIN_TOKENS.ProcessTrackingService).to(ProcessTrackingService);
container.bind(MAIN_TOKENS.PosthogPluginService).to(PosthogPluginService);
container.bind(MAIN_TOKENS.SleepService).to(SleepService);
container.bind(MAIN_TOKENS.ShellService).to(ShellService);
container.bind(MAIN_TOKENS.SlackIntegrationService).to(SlackIntegrationService);
container.bind(MAIN_TOKENS.UIService).to(UIService);
container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService);
container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService);
Expand Down
1 change: 1 addition & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const MAIN_TOKENS = Object.freeze({
HandoffService: Symbol.for("Main.HandoffService"),
GitHubIntegrationService: Symbol.for("Main.GitHubIntegrationService"),
LinearIntegrationService: Symbol.for("Main.LinearIntegrationService"),
SlackIntegrationService: Symbol.for("Main.SlackIntegrationService"),
DeepLinkService: Symbol.for("Main.DeepLinkService"),
NotificationService: Symbol.for("Main.NotificationService"),
McpCallbackService: Symbol.for("Main.McpCallbackService"),
Expand Down
2 changes: 2 additions & 0 deletions apps/code/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
trackAppEvent,
} from "./services/posthog-analytics";
import type { PosthogPluginService } from "./services/posthog-plugin/service";
import type { SlackIntegrationService } from "./services/slack-integration/service";
import type { SuspensionService } from "./services/suspension/service";
import type { TaskLinkService } from "./services/task-link/service";
import type { UpdatesService } from "./services/updates/service";
Expand Down Expand Up @@ -149,6 +150,7 @@ async function initializeServices(): Promise<void> {
container.get<TaskLinkService>(MAIN_TOKENS.TaskLinkService);
container.get<InboxLinkService>(MAIN_TOKENS.InboxLinkService);
container.get<GitHubIntegrationService>(MAIN_TOKENS.GitHubIntegrationService);
container.get<SlackIntegrationService>(MAIN_TOKENS.SlackIntegrationService);
container.get<ExternalAppsService>(MAIN_TOKENS.ExternalAppsService);
container.get<PosthogPluginService>(MAIN_TOKENS.PosthogPluginService);

Expand Down
8 changes: 8 additions & 0 deletions apps/code/src/main/services/slack-integration/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export {
type CloudRegion,
cloudRegion,
type StartIntegrationFlowInput as StartSlackFlowInput,
type StartIntegrationFlowOutput as StartSlackFlowOutput,
startIntegrationFlowInput as startSlackFlowInput,
startIntegrationFlowOutput as startSlackFlowOutput,
} from "../integration-flow-schemas";
162 changes: 162 additions & 0 deletions apps/code/src/main/services/slack-integration/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type { IMainWindow } from "@posthog/platform/main-window";
import type { IUrlLauncher } from "@posthog/platform/url-launcher";
import { getCloudUrlFromRegion } from "@shared/utils/urls";
import { inject, injectable } from "inversify";
import { MAIN_TOKENS } from "../../di/tokens";
import { logger } from "../../utils/logger";
import { TypedEventEmitter } from "../../utils/typed-event-emitter";
import type { DeepLinkService } from "../deep-link/service";
import type { CloudRegion, StartSlackFlowOutput } from "./schemas";

const log = logger.scope("slack-integration-service");

const FLOW_TIMEOUT_MS = 5 * 60 * 1000;

export const SlackIntegrationEvent = {
Callback: "callback",
FlowTimedOut: "flowTimedOut",
} as const;

export interface SlackIntegrationCallback {
projectId: number | null;
integrationId: number | null;
status: "success" | "error";
errorCode: string | null;
errorMessage: string | null;
}

export interface SlackFlowTimedOut {
projectId: number;
}

export interface SlackIntegrationEvents {
[SlackIntegrationEvent.Callback]: SlackIntegrationCallback;
[SlackIntegrationEvent.FlowTimedOut]: SlackFlowTimedOut;
}

/**
* Drives the in-app "Connect Slack" flow:
* 1. The renderer asks for `startFlow(region, projectId)`, which opens the user's
* default browser at PostHog Cloud's Slack OAuth authorize endpoint.
* 2. PostHog Cloud completes Slack OAuth, creates the team-level Slack `Integration`
* row, and redirects to `/account-connected/slack-integration?integration_id=…`,
* which sends a `posthog-code://slack-integration?…` deep link.
* 3. The deep-link handler emits a `Callback` event; renderers refresh integrations.
*
* Mirrors `GitHubIntegrationService` so each provider's deep-link handler is independent.
*/
@injectable()
export class SlackIntegrationService extends TypedEventEmitter<SlackIntegrationEvents> {
private pendingCallback: SlackIntegrationCallback | null = null;
private flowTimeout: ReturnType<typeof setTimeout> | null = null;

constructor(
@inject(MAIN_TOKENS.DeepLinkService)
private readonly deepLinkService: DeepLinkService,
@inject(MAIN_TOKENS.UrlLauncher)
private readonly urlLauncher: IUrlLauncher,
@inject(MAIN_TOKENS.MainWindow)
private readonly mainWindow: IMainWindow,
) {
super();

this.deepLinkService.registerHandler("slack-integration", (_path, params) =>
this.handleCallback(params),
);
}

public async startFlow(
region: CloudRegion,
projectId: number,
): Promise<StartSlackFlowOutput> {
try {
const cloudUrl = getCloudUrlFromRegion(region);
// Lands on PostHog Cloud's AccountConnected page, which forwards to
// `posthog-code://slack-integration?…` with `integration_id` set.
const nextPath = `/account-connected/slack-integration?provider=slack&project_id=${projectId}&connect_from=posthog_code`;
const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=slack&next=${encodeURIComponent(nextPath)}`;

this.clearFlowTimeout();
this.flowTimeout = setTimeout(() => {
log.warn("Slack integration flow timed out", { projectId });
this.flowTimeout = null;
this.emit(SlackIntegrationEvent.FlowTimedOut, { projectId });
}, FLOW_TIMEOUT_MS);

await this.urlLauncher.launch(authorizeUrl);

return { success: true };
} catch (error) {
this.clearFlowTimeout();
log.error("Failed to start Slack integration flow", {
projectId,
error: error instanceof Error ? error.message : String(error),
});
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}

public consumePendingCallback(): SlackIntegrationCallback | null {
const pending = this.pendingCallback;
this.pendingCallback = null;
return pending;
}

private handleCallback(params: URLSearchParams): boolean {
const projectIdRaw = params.get("project_id");
const parsedProjectId = projectIdRaw ? Number(projectIdRaw) : null;
const integrationIdRaw = params.get("integration_id");
const parsedIntegrationId = integrationIdRaw
? Number(integrationIdRaw)
: null;
const status = params.get("status") === "error" ? "error" : "success";

const callback: SlackIntegrationCallback = {
projectId:
parsedProjectId !== null && Number.isFinite(parsedProjectId)
? parsedProjectId
: null,
integrationId:
parsedIntegrationId !== null && Number.isFinite(parsedIntegrationId)
? parsedIntegrationId
: null,
status,
errorCode: params.get("error_code") || null,
errorMessage: params.get("error_message") || null,
};

this.clearFlowTimeout();

if (status === "error") {
log.error("Received Slack integration callback with error", {
projectId: callback.projectId,
errorCode: callback.errorCode,
errorMessage: callback.errorMessage,
});
}

const hasListeners = this.listenerCount(SlackIntegrationEvent.Callback) > 0;
if (hasListeners) {
this.emit(SlackIntegrationEvent.Callback, callback);
} else {
this.pendingCallback = callback;
}

if (this.mainWindow.isMinimized()) {
this.mainWindow.restore();
}
this.mainWindow.focus();

return true;
}

private clearFlowTimeout(): void {
if (this.flowTimeout) {
clearTimeout(this.flowTimeout);
this.flowTimeout = null;
}
}
}
2 changes: 2 additions & 0 deletions apps/code/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { provisioningRouter } from "./routers/provisioning";
import { secureStoreRouter } from "./routers/secure-store";
import { shellRouter } from "./routers/shell";
import { skillsRouter } from "./routers/skills";
import { slackIntegrationRouter } from "./routers/slack-integration";
import { sleepRouter } from "./routers/sleep";
import { suspensionRouter } from "./routers/suspension.js";
import { uiRouter } from "./routers/ui";
Expand Down Expand Up @@ -72,6 +73,7 @@ export const trpcRouter = router({
secureStore: secureStoreRouter,
shell: shellRouter,
skills: skillsRouter,
slackIntegration: slackIntegrationRouter,
ui: uiRouter,
updates: updatesRouter,
deepLink: deepLinkRouter,
Expand Down
62 changes: 62 additions & 0 deletions apps/code/src/main/trpc/routers/slack-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { container } from "../../di/container";
import { MAIN_TOKENS } from "../../di/tokens";
import {
startSlackFlowInput,
startSlackFlowOutput,
} from "../../services/slack-integration/schemas";
import {
type SlackFlowTimedOut,
type SlackIntegrationCallback,
SlackIntegrationEvent,
type SlackIntegrationService,
} from "../../services/slack-integration/service";
import { publicProcedure, router } from "../trpc";

const getService = () =>
container.get<SlackIntegrationService>(MAIN_TOKENS.SlackIntegrationService);

export const slackIntegrationRouter = router({
startFlow: publicProcedure
.input(startSlackFlowInput)
.output(startSlackFlowOutput)
.mutation(({ input }) =>
getService().startFlow(input.region, input.projectId),
),

/**
* Subscribe to Slack integration deep link callbacks emitted after the user
* completes (or errors out of) the Slack OAuth flow on PostHog Cloud.
*/
onCallback: publicProcedure.subscription(async function* (opts) {
const service = getService();
const iterable = service.toIterable(SlackIntegrationEvent.Callback, {
signal: opts.signal,
});
for await (const data of iterable) {
yield data;
}
}),

/**
* Subscribe to flow timeout events (5 minutes with no deep link callback).
*/
onFlowTimedOut: publicProcedure.subscription(async function* (opts) {
const service = getService();
const iterable = service.toIterable(SlackIntegrationEvent.FlowTimedOut, {
signal: opts.signal,
});
for await (const data of iterable) {
yield data;
}
}),

/**
* Get any integration callback that arrived before the renderer subscribed.
*/
consumePendingCallback: publicProcedure.query(
(): SlackIntegrationCallback | null =>
getService().consumePendingCallback(),
),
});

export type { SlackIntegrationCallback, SlackFlowTimedOut };
33 changes: 30 additions & 3 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
SignalReportTaskRelationship,
SignalTeamConfig,
SignalUserAutonomyConfig,
SlackChannelsResponse,
SuggestedReviewersArtefact,
Task,
TaskRun,
Expand Down Expand Up @@ -2250,9 +2251,14 @@ export class PostHogAPIClient {
return (await response.json()) as SignalUserAutonomyConfig;
}

async updateSignalUserAutonomyConfig(updates: {
autostart_priority: string | null;
}): Promise<SignalUserAutonomyConfig> {
async updateSignalUserAutonomyConfig(
updates: Partial<{
autostart_priority: string | null;
slack_notification_integration_id: number | null;
slack_notification_channel: string | null;
slack_notification_min_priority: string | null;
}>,
): Promise<SignalUserAutonomyConfig> {
const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`);
const path = "/api/users/@me/signal_autonomy/";

Expand All @@ -2273,6 +2279,27 @@ export class PostHogAPIClient {
return (await response.json()) as SignalUserAutonomyConfig;
}

async getSlackChannelsForIntegration(
integrationId: number,
): Promise<SlackChannelsResponse> {
const teamId = await this.getTeamId();
const url = new URL(
`${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/channels/`,
);
const path = `/api/environments/${teamId}/integrations/${integrationId}/channels/`;

const response = await this.api.fetcher.fetch({
method: "get",
url,
path,
});

if (!response.ok) {
throw new Error(`Failed to fetch Slack channels: ${response.statusText}`);
}
return (await response.json()) as SlackChannelsResponse;
}

async deleteSignalUserAutonomyConfig(): Promise<void> {
const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`);
const path = "/api/users/@me/signal_autonomy/";
Expand Down
Loading
Loading