Skip to content
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 };
48 changes: 45 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,8 @@ import type {
SignalReportTaskRelationship,
SignalTeamConfig,
SignalUserAutonomyConfig,
SlackChannelsQueryParams,
SlackChannelsResponse,
SuggestedReviewersArtefact,
Task,
TaskRun,
Expand Down Expand Up @@ -2250,9 +2252,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 +2280,41 @@ export class PostHogAPIClient {
return (await response.json()) as SignalUserAutonomyConfig;
}

async getSlackChannelsForIntegration(
integrationId: number,
params?: SlackChannelsQueryParams,
): Promise<SlackChannelsResponse> {
const teamId = await this.getTeamId();
const url = new URL(
`${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/channels/`,
);
const search = params?.search?.trim();
if (search) {
url.searchParams.set("search", search);
}
if (params?.limit != null) {
url.searchParams.set("limit", String(params.limit));
}
if (params?.offset != null) {
url.searchParams.set("offset", String(params.offset));
}
if (params?.channelId) {
url.searchParams.set("channel_id", params.channelId);
}
const path = `/api/environments/${teamId}/integrations/${integrationId}/channels/${url.search}`;

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