From 6c837f4ab6978cb0ac69cc0eafabad0e8ab104b3 Mon Sep 17 00:00:00 2001 From: t3 agent Date: Sat, 18 Jul 2026 06:30:57 +0000 Subject: [PATCH 1/4] Add multi-platform chat adapter plan --- docs/integrations/chat-platform-adapters.md | 395 ++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 docs/integrations/chat-platform-adapters.md diff --git a/docs/integrations/chat-platform-adapters.md b/docs/integrations/chat-platform-adapters.md new file mode 100644 index 00000000000..08d0369da8c --- /dev/null +++ b/docs/integrations/chat-platform-adapters.md @@ -0,0 +1,395 @@ +# Chat Platform Adapters: Teams first, Slack optional + +**Status:** plan +**Branch context:** `fork/discord` +**Scope:** support Microsoft Teams as a first-class alternative to Discord, and make Slack a low-friction follow-up adapter. + +## Summary + +T3 Code already has most of the hard logic for a chat bridge: + +- T3 session bootstrap and orchestration dispatch +- thread link persistence +- turn lifecycle handling +- approval / stop controls +- stream vs finalize behavior +- attachment recovery and reposting + +What is still tightly coupled to Discord is the transport and conversation model: + +- bot mention parsing +- channel topic project binding +- Discord thread ids and thread titles +- Discord message edit and multipart upload semantics +- Discord component ids and ephemeral interaction replies + +The implementation plan is to extract a platform-neutral bridge core, keep Discord as the reference adapter, then add Teams on top of the new boundary. Slack should follow only after the adapter contract is proven by Teams. + +## Why Teams first + +Teams is the better forcing function for the abstraction: + +- it breaks more Discord-specific assumptions than Slack does +- it has stronger enterprise distribution value +- it adds product surfaces Discord does not have, especially message extensions and richer Microsoft 365 placement + +If the adapter design can support Teams cleanly, Slack should be comparatively straightforward. + +## Goals + +1. Preserve current Discord behavior while extracting reusable bridge logic. +2. Add a Teams adapter that supports the core Discord-bot workflow: + - start a task from chat + - continue a linked conversation + - approve / deny / stop + - stream progress reasonably + - post final answers with files and images +3. Make Slack a follow-up adapter with minimal additional core refactoring. +4. Replace Discord-only conversation binding assumptions with a platform-neutral binding model. + +## Non-goals + +- Perfectly identical UX across Discord, Teams, and Slack. +- Meeting, voice, or shared-channel-first experiences. +- Full Teams message extension and Slack slash-command feature sets in the first adapter PR. +- Server-side multi-platform schema changes unrelated to the bridge. + +## Current repo shape + +The current Discord integration is already close to the right split: + +- transport and platform behavior live mostly under `apps/discord-bot/src/{discord,features,presentation}` +- T3 orchestration is centralized in `apps/discord-bot/src/t3/T3Session.ts` +- durable thread linkage is isolated in `apps/discord-bot/src/store/ThreadLinkStore.ts` +- `docs/integrations/discord-bot.md` already notes that T3 bridge services should stay reusable for a future Slack adapter + +The missing step is a formal adapter boundary. + +## Proposed architecture + +```text +platform event + -> platform adapter + -> bridge core + -> T3 session + -> bridge core + -> platform adapter + -> platform message / file / card update +``` + +### Bridge core + +Owns: + +- T3 session connection and reconnect policy +- project resolution and conversation binding flow +- linked conversation persistence +- turn coordination and interrupt safety +- assistant stream state +- finalization rules +- approval state mapping +- attachment normalization between platform input and T3 upload payloads + +Does not own: + +- raw webhook / gateway handling +- platform mention syntax +- channel, thread, reply, or chat ids +- platform-specific message rendering primitives +- card or button payload formats + +### Adapter boundary + +Introduce a `ChatPlatformAdapter` interface with these responsibilities: + +- normalize inbound events into a shared `PlatformTurnInput` +- expose platform capabilities +- render shared bridge actions into platform-native message operations +- persist enough platform identifiers to resume and finalize correctly + +Suggested capability flags: + +- `supportsEditableMessages` +- `supportsThreadReplies` +- `supportsButtons` +- `supportsEphemeralReplies` +- `supportsPinnedInfoMessage` +- `supportsConversationRename` +- `supportsUserImageUploads` +- `supportsFileUploads` +- `supportsPersonalChat` + +Suggested shared operations: + +- `postWorkingMessage` +- `updateWorkingMessage` +- `postFinalMessages` +- `deleteMessages` +- `postApprovalRequest` +- `acknowledgeInteraction` +- `postInfoMessage` +- `resolveConversationBinding` + +## Binding model changes + +Discord currently uses channel topics with `t3-`. That does not generalize. + +Replace this with two durable stores: + +### `ProjectBindingStore` + +Maps a platform conversation to a T3 project or workspace root. + +Examples: + +- Discord channel id -> workspace root +- Teams channel or chat id -> workspace root +- Slack channel id -> workspace root + +### `ConversationLinkStore` + +Maps a platform conversation thread to a T3 thread. + +Examples: + +- Discord thread id -> T3 thread id +- Teams parent message or reply chain id -> T3 thread id +- Slack `channel + thread_ts` -> T3 thread id + +The current `ThreadLinkStore` can evolve into the second store, but the name and schema should become platform-neutral. + +## Phased implementation plan + +### PR 1: extract the bridge core and preserve Discord parity + +Deliverables: + +- create a reusable bridge core package or shared module +- move Discord-only logic behind an adapter +- keep the current Discord app behavior unchanged +- convert `ThreadLinkStore` into a platform-neutral link schema or wrap it with a neutral interface +- add capability-driven rendering paths where Discord currently assumes editable tips, topic binding, and component ids + +Success criteria: + +- existing Discord flows still work +- no user-visible Discord behavior regression +- tests still cover stream, finalize, approvals, stop, attachments, and reconnect paths + +### PR 2: Teams adapter MVP + +Deliverables: + +- new Teams bridge app or adapter package +- channel and chat entrypoints +- linked conversation continuation +- approval / deny / stop actions via Adaptive Cards or equivalent invoke actions +- final answer posting with file and image support where platform support is practical +- project binding flow that does not depend on channel topic metadata + +Recommended MVP surfaces: + +- channel conversations +- standard replies to a parent message +- personal chat if it is easy once the bot is wired + +Defer: + +- message extensions +- tabs +- meeting surfaces +- shared channels + +### PR 3: Slack adapter MVP + +Deliverables: + +- mention-based start and continue flow in channels +- threaded continuation using `thread_ts` +- approval / deny / stop controls with Block Kit buttons +- final answer and file/image posting +- optional slash command bootstrap outside threads + +Defer: + +- modals +- App Home +- complex admin or distribution flows + +### PR 4: platform-specific enhancements + +Possible follow-up work: + +- Teams message extensions for “create task”, “search/share result”, or “investigate here” +- Teams personal-chat-first workflow +- Slack slash command improvements +- richer per-platform info/help surfaces +- conversation restore and resume improvements across all adapters + +## Feature parity overview + +| Capability | Discord today | Teams target | Slack target | +| ------------------------------------------------------- | ------------- | ------------------------------------------- | ------------ | +| Start from mention in shared conversation | Yes | Yes | Yes | +| Continue linked conversation in reply/thread context | Yes | Yes | Yes | +| Streaming progress into the conversation | Strong | Good enough, likely less polished | Strong | +| Approve / deny controls | Yes | Yes | Yes | +| Stop active turn | Yes | Yes | Yes | +| Final answer split across multiple platform messages | Yes | Yes | Yes | +| Attach images and local files on finalize | Yes | Partial-to-strong | Strong | +| Accept user image attachments as turn input | Yes | Yes, with platform-specific attachment work | Yes | +| Project binding without server-side project duplication | Yes | Yes | Yes | +| Conversation restore after reconnect/restart | Planned | Planned | Planned | +| Mirror T3 thread title back to platform | Yes | Optional | Optional | +| Pinned channel help / setup surface | Yes | Optional | Optional | +| Ops alert channel | Yes | Yes | Yes | + +## What each platform uniquely adds + +### Discord + +Strengths: + +- best current streaming UX for this repo +- easy project binding via channel topic +- public threads map well to T3 linked conversations +- simple component interaction model + +Weaknesses: + +- weaker enterprise deployment fit +- less compelling for Microsoft-centric organizations + +### Teams + +Strengths: + +- strong enterprise distribution inside Microsoft 365 +- can operate in personal chat, group chat, and channel scopes +- message extensions are a meaningful future differentiator +- Adaptive Cards provide a richer structured UI surface than plain chat messages + +Weaknesses: + +- more constrained formatting and card behavior +- more client inconsistency than the abstraction should ignore +- less natural fit for Discord-style live “edit the tip message” streaming + +### Slack + +Strengths: + +- native thread model maps cleanly to the current T3 conversation design +- Block Kit provides strong button and layout support +- slash commands are useful as an explicit bootstrap path outside threads + +Weaknesses: + +- fewer unique product advantages than Teams for this repo's next step +- distributed app rate-limit constraints can matter for some install models + +## Platform constraints that should shape implementation + +### Teams + +- Microsoft documents bot scope support across personal chat, group chat, and channels. +- Teams message posting supports chats, channels, and channel replies. +- Teams shared channels currently do not support bots, connectors, or message extensions. +- Teams Adaptive Cards support is narrower than the generic Bot Framework surface suggests, especially across client versions and mobile. + +Implication: + +Do not design the bridge around shared channels, advanced card features, or Discord-style message editing assumptions. + +### Slack + +- Slack threads map cleanly to `channel + thread_ts`. +- Slack slash commands cannot be invoked inside message threads. +- Slack file upload flows should use the current upload approach documented by Slack rather than old deprecated patterns. +- Slack changed `conversations.history` and `conversations.replies` rate limits on May 29, 2025 for new non-Marketplace commercially distributed apps and new installs of existing distributed apps. Slack explicitly says internal customer-built apps are not impacted. + +Implication: + +Prefer event-driven linked-thread continuation and avoid designs that require high-volume thread-history polling. + +## Recommended repo changes + +### Structure + +One reasonable shape: + +```text +apps/chat-bridge-core/ +apps/discord-bot/ +apps/teams-bot/ +apps/slack-bot/ +``` + +Alternative: + +```text +packages/chat-bridge-core/ +apps/discord-bot/ +apps/teams-bot/ +apps/slack-bot/ +``` + +Use whichever layout best fits the monorepo's current package boundaries. The important part is that platform-neutral runtime logic should not continue living only inside `apps/discord-bot`. + +### Shared modules to extract first + +- conversation link store +- turn coordinator +- bridge state machine +- finalization policy +- approval state rendering model +- attachment normalization and T3 upload translation +- shared prompt/bootstrap policy for first-turn context + +### Discord-specific modules that should remain adapter-local + +- mention parsing +- role mention ambiguity handling +- channel topic parsing +- Discord component ids +- Discord multipart file upload details +- thread rename and pin behaviors + +## Risks + +1. The first refactor may accidentally encode Discord assumptions in the “shared” core if the adapter boundary is defined too late. +2. Teams may tempt over-investment in rich cards before the basic reply-linked workflow is stable. +3. Slack can look easy because its threads map cleanly, but distribution and rate-limit assumptions need to stay explicit. +4. If project binding is not made neutral early, every adapter will invent its own local configuration path. + +## Recommendation for the initial implementation PR + +The first code PR after this plan should be the abstraction PR, not the Teams adapter. + +That PR should: + +- extract the bridge core +- keep Discord fully functional +- convert storage and rendering boundaries to platform-neutral interfaces +- leave the repo in a state where a Teams adapter can be added without reworking the Discord app again + +## References + +- Current Discord integration: `docs/integrations/discord-bot.md` +- Current Discord router: `apps/discord-bot/src/features/MentionRouter.ts` +- Current bridge implementation: `apps/discord-bot/src/features/ResponseBridge.ts` +- Current T3 transport layer: `apps/discord-bot/src/t3/T3Session.ts` + +External platform references used for this plan: + +- Slack slash commands: +- Slack thread replies: +- Slack thread retrieval and limits: +- Slack file uploads: +- Slack rate-limit changes dated May 29, 2025: +- Teams bots overview: +- Teams message extensions: +- Teams message posting: +- Teams cards reference: +- Teams card actions: +- Teams limits: From 4ba40a7a614e1d296db571b8b7f1c25616b7f65d Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:42:45 +0000 Subject: [PATCH 2/4] feat(discord-bot): port Teams intake module + message-action plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase the chat-platform adapter plan onto current fork/discord and fold in the other Teams work from the legacy private repo: - aaaomega/t3code-pvt#1 — Graph-based Teams intake (poll → Discord/T3) - aaaomega/t3code-pvt#6 — Teams message-action planning doc Wire optional TEAMS_* config, sourceKind/sourceThreadId link keys, shared LinkedTurnRouter start/continue helper, and docs cross-links. Teams stays off unless TEAMS_ENABLED=1. --- .env.example | 10 + apps/discord-bot/src/config.test.ts | 7 + apps/discord-bot/src/config.ts | 39 ++ .../src/features/LinkedTurnRouter.ts | 237 +++++++ apps/discord-bot/src/features/TeamsModule.ts | 598 ++++++++++++++++++ apps/discord-bot/src/main.ts | 12 + apps/discord-bot/src/store/TeamsSeenStore.ts | 87 +++ apps/discord-bot/src/store/ThreadLinkStore.ts | 38 ++ apps/discord-bot/src/teams/attachments.ts | 147 +++++ apps/discord-bot/src/teams/config.test.ts | 52 ++ apps/discord-bot/src/teams/config.ts | 116 ++++ .../src/teams/presentation.test.ts | 80 +++ apps/discord-bot/src/teams/presentation.ts | 338 ++++++++++ apps/discord-bot/teams.channels.example.json | 22 + docs/integrations/chat-platform-adapters.md | 14 + .../microsoft-teams-discord-bot.md | 162 +++++ .../microsoft-teams-message-action-plan.md | 186 ++++++ 17 files changed, 2145 insertions(+) create mode 100644 apps/discord-bot/src/features/LinkedTurnRouter.ts create mode 100644 apps/discord-bot/src/features/TeamsModule.ts create mode 100644 apps/discord-bot/src/store/TeamsSeenStore.ts create mode 100644 apps/discord-bot/src/teams/attachments.ts create mode 100644 apps/discord-bot/src/teams/config.test.ts create mode 100644 apps/discord-bot/src/teams/config.ts create mode 100644 apps/discord-bot/src/teams/presentation.test.ts create mode 100644 apps/discord-bot/src/teams/presentation.ts create mode 100644 apps/discord-bot/teams.channels.example.json create mode 100644 docs/integrations/microsoft-teams-discord-bot.md create mode 100644 docs/integrations/microsoft-teams-message-action-plan.md diff --git a/.env.example b/.env.example index 5dcd2775e6f..df0c81d0563 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,13 @@ # T3CODE_MOBILE_IOS_PERSONAL_TEAM=1 # T3CODE_MOBILE_EAS_PROJECT_ID=00000000-0000-0000-0000-000000000000 # T3CODE_MOBILE_EXPO_OWNER=your-expo-username + +# Optional: Discord bot Teams intake module (Graph poll → Discord/T3). +# See docs/integrations/microsoft-teams-discord-bot.md +# TEAMS_ENABLED=1 +# TEAMS_TENANT_ID=00000000-0000-0000-0000-000000000000 +# TEAMS_CLIENT_ID=00000000-0000-0000-0000-000000000000 +# TEAMS_CLIENT_SECRET=... +# TEAMS_CHANNELS_PATH=/absolute/path/to/apps/discord-bot/teams.channels.json +# TEAMS_POLL_INTERVAL_SECONDS=60 +# TEAMS_BOT_DISPLAY_NAME=T3 Code diff --git a/apps/discord-bot/src/config.test.ts b/apps/discord-bot/src/config.test.ts index 6aa94bca270..4501c19388b 100644 --- a/apps/discord-bot/src/config.test.ts +++ b/apps/discord-bot/src/config.test.ts @@ -26,6 +26,13 @@ const baseConfig = { browserFfmpegPath: "ffmpeg", browserAllowedOrigins: [], jiraBrowseBaseUrl: "https://example.atlassian.net", + teamsEnabled: false, + teamsTenantId: undefined, + teamsClientId: undefined, + teamsClientSecret: undefined, + teamsChannelsPath: undefined, + teamsPollIntervalSeconds: 60, + teamsBotDisplayName: undefined, } satisfies DiscordBotConfig; describe("preferredModelSelection", () => { diff --git a/apps/discord-bot/src/config.ts b/apps/discord-bot/src/config.ts index 2741cda27e4..e73915a2de3 100644 --- a/apps/discord-bot/src/config.ts +++ b/apps/discord-bot/src/config.ts @@ -59,6 +59,14 @@ export interface DiscordBotConfig { * (e.g. `https://example.atlassian.net`). */ readonly jiraBrowseBaseUrl: string | undefined; + /** Optional Microsoft Teams intake module (Graph channel polling → Discord/T3). */ + readonly teamsEnabled: boolean; + readonly teamsTenantId: string | undefined; + readonly teamsClientId: string | undefined; + readonly teamsClientSecret: string | undefined; + readonly teamsChannelsPath: string | undefined; + readonly teamsPollIntervalSeconds: number; + readonly teamsBotDisplayName: string | undefined; } const RuntimeModeConfig = Schema.Literals([ @@ -167,6 +175,30 @@ export const DiscordBotConfig: Effect.Effect (Option.isSome(value) ? Redacted.value(value.value) : undefined)), + ); + const teamsChannelsPath = yield* Config.string("TEAMS_CHANNELS_PATH").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const teamsPollIntervalSeconds = yield* Config.int("TEAMS_POLL_INTERVAL_SECONDS").pipe( + Config.withDefault(60), + ); + const teamsBotDisplayName = yield* Config.string("TEAMS_BOT_DISPLAY_NAME").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); return { discordToken, @@ -191,6 +223,13 @@ export const DiscordBotConfig: Effect.Effect | undefined; + readonly announceLines: ReadonlyArray; + readonly promptContext?: + | LinkedTurnPromptContext + | { + readonly kind: "raw"; + readonly value: string; + } + | undefined; +} + +/** + * Shared start/continue path for Discord mentions and Teams intake. + * Ported from aaaomega/t3code-pvt#1 and adapted to the current ThreadLinkStore + bridge APIs. + */ +export const resolveProjectFromShortName = Effect.fn("resolveProjectFromShortName")(function* ( + projectShortName: string, +) { + const aliases = yield* ProjectAliasStore; + const t3 = yield* T3Session; + + const alias = aliases.resolve(projectShortName); + if (alias === null) { + return yield* Effect.fail( + `Unknown project alias '${projectShortName}'. Add it to the bot aliases file (T3_PROJECT_ALIASES_PATH).` as const, + ); + } + const project = yield* t3.findProjectByWorkspaceRoot(alias.workspaceRoot); + if (project === null) { + return yield* Effect.fail( + `No T3 project registered at ${alias.workspaceRoot} (alias '${projectShortName}'). Add the project in T3 first.` as const, + ); + } + + return { alias, project }; +}); + +function resolveInitialPrompt(input: { + readonly config: DiscordBotConfig; + readonly projectShortName: string; + readonly workspaceRoot: string; + readonly prompt: string; + readonly promptContext: LinkedTurnInput["promptContext"]; + readonly guildId: string; + readonly discordThreadId: string; +}): string { + if (input.promptContext?.kind === "raw") { + return input.promptContext.value; + } + + if (input.promptContext?.kind === "discord") { + const starter = input.promptContext.starter ?? input.promptContext.mentionMessage ?? null; + const sentryBootstrap = looksLikeSentryContext({ + starter, + mentionPrompt: input.prompt, + }); + if (sentryBootstrap || starter !== null) { + return buildFirstTurnPrompt({ + starter, + mentionMessage: input.promptContext.mentionMessage, + mentionPrompt: input.prompt, + projectShortName: input.projectShortName, + workspaceRoot: input.workspaceRoot, + honeycombTraceUrlTemplate: input.config.honeycombTraceUrlTemplate, + jiraBrowseBaseUrl: input.config.jiraBrowseBaseUrl, + guildId: input.guildId, + discordThreadId: input.discordThreadId, + }); + } + } + + return input.prompt; +} + +export const startOrContinueLinkedTurn = Effect.fn("startOrContinueLinkedTurn")(function* ( + config: DiscordBotConfig, + input: LinkedTurnInput, +) { + const rest = yield* DiscordREST; + const t3 = yield* T3Session; + const links = yield* ThreadLinkStore; + const attachments = input.attachments ?? []; + const hasExplicitModelFlags = + input.flags.provider !== undefined || input.flags.model !== undefined; + + const resolved = yield* resolveProjectFromShortName(input.projectShortName); + + const existing = yield* links.getBySourceThread( + input.source.sourceKind, + input.source.sourceThreadId, + ); + if (existing !== null) { + const workingAckResult = yield* rest + .createMessage(input.discordThreadId, { + ...workingMessageFields("_Working.._", existing.t3ThreadId), + }) + .pipe( + Effect.map((message) => message.id as string), + Effect.tap((messageId) => Effect.logInfo("Posted Working.. ack", { messageId })), + Effect.result, + ); + if (Result.isFailure(workingAckResult)) { + yield* Effect.logError("Failed to post Working.. ack"); + yield* Effect.logError(workingAckResult.failure); + } + const workingAckMessageId = Result.isSuccess(workingAckResult) + ? workingAckResult.success + : null; + + yield* bridgeThreadToDiscord({ + discordChannelId: input.discordThreadId, + t3ThreadId: existing.t3ThreadId, + workingAckMessageId, + }); + const continueModelSelection = + input.stickyModelOnContinue === true && !hasExplicitModelFlags + ? undefined + : yield* t3.resolveModelSelection({ + project: resolved.project, + ...(input.flags.provider === undefined + ? {} + : { overrideInstanceId: input.flags.provider }), + ...(input.flags.model === undefined ? {} : { overrideModel: input.flags.model }), + }); + yield* t3.startTurn({ + threadId: existing.t3ThreadId, + prompt: input.prompt, + ...(continueModelSelection === undefined ? {} : { modelSelection: continueModelSelection }), + ...(input.flags.plan ? { interactionMode: "plan" as const } : {}), + ...(attachments.length > 0 ? { attachments } : {}), + }); + return existing.t3ThreadId; + } + + const modelSelection = yield* t3.resolveModelSelection({ + project: resolved.project, + ...(input.flags.provider === undefined ? {} : { overrideInstanceId: input.flags.provider }), + ...(input.flags.model === undefined ? {} : { overrideModel: input.flags.model }), + }); + + const enrichedPrompt = resolveInitialPrompt({ + config, + projectShortName: input.projectShortName, + workspaceRoot: resolved.project.workspaceRoot, + prompt: input.prompt, + promptContext: input.promptContext, + guildId: input.discordGuildId, + discordThreadId: input.discordThreadId, + }); + + const { threadId } = yield* t3.startTurnWithWorktree({ + project: resolved.project, + prompt: enrichedPrompt, + modelSelection, + interactionMode: input.flags.plan ? "plan" : "default", + baseBranch: input.flags.base ?? config.t3DefaultBaseBranch, + local: input.flags.local ?? false, + ...(attachments.length > 0 ? { attachments } : {}), + }); + + yield* links.put({ + sourceKind: input.source.sourceKind, + sourceThreadId: input.source.sourceThreadId, + discordThreadId: input.discordThreadId, + t3ThreadId: threadId, + projectId: resolved.project.id, + channelId: input.discordParentChannelId, + guildId: input.discordGuildId, + createdAt: DateTime.formatIso(DateTime.nowUnsafe()), + }); + + const webLink = + config.webUiBaseUrl === undefined + ? null + : `${config.webUiBaseUrl.replace(/\/$/u, "")}/?thread=${threadId}`; + + yield* rest.createMessage(input.discordThreadId, { + content: [...input.announceLines, webLink === null ? null : `Open in Omegent: ${webLink}`] + .filter((line): line is string => line !== null && line.trim().length > 0) + .join("\n"), + }); + + yield* bridgeThreadToDiscord({ + discordChannelId: input.discordThreadId, + t3ThreadId: threadId, + }); + + return threadId; +}); diff --git a/apps/discord-bot/src/features/TeamsModule.ts b/apps/discord-bot/src/features/TeamsModule.ts new file mode 100644 index 00000000000..a668e3da6f8 --- /dev/null +++ b/apps/discord-bot/src/features/TeamsModule.ts @@ -0,0 +1,598 @@ +// @effect-diagnostics anyUnknownInErrorContext:off missingEffectContext:off globalFetch:off globalTimers:off missingEffectError:off globalDateInEffect:off globalDate:off globalErrorInEffectCatch:off globalErrorInEffectFailure:off globalFetchInEffect:off preferSchemaOverJson:off +import { DiscordConfig, DiscordREST } from "dfx"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; + +import type { DiscordBotConfig } from "../config.ts"; +import { createMessageWithAttachments, DiscordUploadError } from "../presentation/discordFiles.ts"; +import { TeamsSeenStore } from "../store/TeamsSeenStore.ts"; +import { ThreadLinkStore } from "../store/ThreadLinkStore.ts"; +import { truncateTitle } from "../presentation/messages.ts"; +import { startOrContinueLinkedTurn } from "./LinkedTurnRouter.ts"; +import type { DiscordUploadFile } from "../presentation/discordFiles.ts"; +import { downloadTeamsMessageImages } from "../teams/attachments.ts"; +import { loadTeamsChannelConfigsFromFileSync, type TeamsChannelConfig } from "../teams/config.ts"; +import { + buildTeamsIncidentTitle, + buildTeamsPrompt, + buildTeamsSeedMessage, + hasAllowlistedReaction, + hasInternalTagTrigger, + isHumanTeamsMessage, + looksLikeGermanProblemReport, + mentionsTeamsBot, + rootTeamsMessageId, + teamsMessageTimestamp, + type TeamsMessage, +} from "../teams/presentation.ts"; + +interface GraphListResponse { + readonly value?: ReadonlyArray | undefined; + readonly "@odata.nextLink"?: string | undefined; +} + +interface GraphHostedContentResponse { + readonly value?: + | ReadonlyArray<{ + readonly id?: string | undefined; + readonly contentType?: string | undefined; + }> + | undefined; +} + +function channelKey(channel: TeamsChannelConfig): string { + return `${channel.teamId}/${channel.channelId}`; +} + +function sourceThreadKey(channel: TeamsChannelConfig, rootMessageId: string): string { + return `${channel.teamId}/${channel.channelId}/${rootMessageId}`; +} + +function oauthRequestBody(config: DiscordBotConfig): URLSearchParams { + const body = new URLSearchParams(); + body.set("grant_type", "client_credentials"); + body.set("client_id", config.teamsClientId ?? ""); + body.set("client_secret", config.teamsClientSecret ?? ""); + body.set("scope", "https://graph.microsoft.com/.default"); + return body; +} + +async function fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise { + const response = await globalThis.fetch(input, init); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return (await response.json()) as unknown; +} + +function nowLookbackStart(): Date { + const now = new Date(); + const dayOfWeek = now.getUTCDay(); + if (dayOfWeek === 6 || dayOfWeek === 0 || dayOfWeek === 1) { + const daysSinceFriday = dayOfWeek === 6 ? 1 : dayOfWeek === 0 ? 2 : 3; + const friday = new Date(now); + friday.setUTCDate(now.getUTCDate() - daysSinceFriday); + friday.setUTCHours(0, 0, 0, 0); + return friday; + } + + return new Date(now.getTime() - 24 * 60 * 60 * 1000); +} + +export const runTeamsModule = Effect.fn("runTeamsModule")(function* (config: DiscordBotConfig) { + if (!config.teamsEnabled) { + yield* Effect.logInfo("Teams module disabled"); + return; + } + if ( + !config.teamsTenantId || + !config.teamsClientId || + !config.teamsClientSecret || + !config.teamsChannelsPath + ) { + yield* Effect.logWarning("Teams module enabled but auth/config is incomplete; skipping"); + return; + } + + const channels = loadTeamsChannelConfigsFromFileSync(config.teamsChannelsPath); + if (channels.length === 0) { + yield* Effect.logWarning("Teams module enabled but channel config is empty; skipping"); + return; + } + + const rest = yield* DiscordREST; + const discordConfig = yield* DiscordConfig.DiscordConfig; + const seenStore = yield* TeamsSeenStore; + const links = yield* ThreadLinkStore; + + let cachedAccessToken: { readonly token: string; readonly expiresAt: number } | null = null; + + const getAccessToken = Effect.fn("runTeamsModule.getAccessToken")(function* () { + const now = Date.now(); + if (cachedAccessToken !== null && cachedAccessToken.expiresAt > now + 60_000) { + return cachedAccessToken.token; + } + + const tokenResponse = (yield* Effect.tryPromise({ + try: () => + fetchJson(`https://login.microsoftonline.com/${config.teamsTenantId}/oauth2/v2.0/token`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: oauthRequestBody(config), + }), + catch: (cause) => new Error(String(cause)), + })) as { + readonly access_token?: string; + readonly expires_in?: number; + }; + + const token = tokenResponse.access_token ?? ""; + const expiresIn = tokenResponse.expires_in ?? 3600; + if (token.length === 0) { + return yield* Effect.fail( + new Error("Microsoft Graph token response did not include access_token."), + ); + } + cachedAccessToken = { + token, + expiresAt: now + expiresIn * 1000, + }; + return token; + }); + + const graphGet = Effect.fn("runTeamsModule.graphGet")(function* (pathOrUrl: string) { + const accessToken = yield* getAccessToken(); + return (yield* Effect.tryPromise({ + try: () => + fetchJson( + pathOrUrl.startsWith("https://") + ? pathOrUrl + : `https://graph.microsoft.com/v1.0${pathOrUrl}`, + { + headers: { + authorization: `Bearer ${accessToken}`, + }, + }, + ), + catch: (cause) => new Error(String(cause)), + })) as GraphListResponse; + }); + + const graphGetUnknown = Effect.fn("runTeamsModule.graphGetUnknown")(function* (path: string) { + const accessToken = yield* getAccessToken(); + return yield* Effect.tryPromise({ + try: () => + fetchJson(`https://graph.microsoft.com/v1.0${path}`, { + headers: { + authorization: `Bearer ${accessToken}`, + }, + }), + catch: (cause) => new Error(String(cause)), + }); + }); + + const messageIsWithinLookback = (message: TeamsMessage, lookbackStartMs: number): boolean => { + const createdAt = Date.parse(message.createdDateTime ?? ""); + return !Number.isFinite(createdAt) || createdAt >= lookbackStartMs; + }; + + const listPagedMessages = Effect.fn("runTeamsModule.listPagedMessages")(function* ( + initialPath: string, + lookbackStartMs: number, + ) { + const collected: TeamsMessage[] = []; + let cursor: string | null = initialPath; + + while (cursor !== null) { + const page: GraphListResponse = yield* graphGet(cursor); + const pageMessages: TeamsMessage[] = [...(page.value ?? [])]; + collected.push(...pageMessages); + + const shouldContinue: boolean = pageMessages.some((message: TeamsMessage) => + messageIsWithinLookback(message, lookbackStartMs), + ); + cursor = shouldContinue ? (page["@odata.nextLink"] ?? null) : null; + } + + return collected.filter((message) => messageIsWithinLookback(message, lookbackStartMs)); + }); + + const postTeamsWebhookAck = Effect.fn("runTeamsModule.postTeamsWebhookAck")(function* ( + channel: TeamsChannelConfig, + message: TeamsMessage, + ) { + if (!channel.respondToMentions || !channel.teamsIncomingWebhookUrl) return; + yield* Effect.tryPromise({ + try: () => + globalThis.fetch(channel.teamsIncomingWebhookUrl!, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + text: `Triage started for ${channel.company}/${channel.environment}: ${truncateTitle( + buildTeamsIncidentTitle({ + company: channel.company, + environment: channel.environment, + message, + }), + 140, + )}`, + }), + }), + catch: (cause) => new Error(String(cause)), + }).pipe(Effect.ignore); + }); + + const openDiscordThread = Effect.fn("runTeamsModule.openDiscordThread")(function* ( + channel: TeamsChannelConfig, + message: TeamsMessage, + reason: "mention" | "german-problem" | "allowlisted-reaction" | "internal-tag", + imageFiles: ReadonlyArray, + ) { + const seedContent = buildTeamsSeedMessage({ + company: channel.company, + environment: channel.environment, + channelName: channel.channelName, + message, + reason, + }); + const seed = + imageFiles.length === 0 + ? yield* rest.createMessage(channel.discordChannelId, { + content: seedContent, + }) + : yield* Effect.tryPromise({ + try: () => + createMessageWithAttachments({ + baseUrl: discordConfig.rest.baseUrl, + botToken: Redacted.value(discordConfig.token), + channelId: channel.discordChannelId, + content: seedContent, + files: imageFiles, + }), + catch: (cause) => + cause instanceof DiscordUploadError + ? cause + : new DiscordUploadError(cause instanceof Error ? cause.message : String(cause)), + }); + const discordThread = yield* rest.createThreadFromMessage(channel.discordChannelId, seed.id, { + name: truncateTitle( + buildTeamsIncidentTitle({ + company: channel.company, + environment: channel.environment, + message, + }), + ), + auto_archive_duration: 1440, + }); + return discordThread.id; + }); + + const hostedContentsPath = (channel: TeamsChannelConfig, message: TeamsMessage): string => + message.replyToId + ? `/teams/${encodeURIComponent(channel.teamId)}/channels/${encodeURIComponent(channel.channelId)}/messages/${encodeURIComponent(message.replyToId)}/replies/${encodeURIComponent(message.id)}/hostedContents` + : `/teams/${encodeURIComponent(channel.teamId)}/channels/${encodeURIComponent(channel.channelId)}/messages/${encodeURIComponent(message.id)}/hostedContents`; + + const buildHostedContentValueUrl = ( + channel: TeamsChannelConfig, + message: TeamsMessage, + hostedContentId: string, + ): string => + `https://graph.microsoft.com/v1.0${ + message.replyToId + ? `/teams/${encodeURIComponent(channel.teamId)}/channels/${encodeURIComponent(channel.channelId)}/messages/${encodeURIComponent(message.replyToId)}/replies/${encodeURIComponent(message.id)}/hostedContents/${encodeURIComponent(hostedContentId)}/$value` + : `/teams/${encodeURIComponent(channel.teamId)}/channels/${encodeURIComponent(channel.channelId)}/messages/${encodeURIComponent(message.id)}/hostedContents/${encodeURIComponent(hostedContentId)}/$value` + }`; + + const listHostedContentsForMessage = Effect.fn("runTeamsModule.listHostedContentsForMessage")( + function* (channel: TeamsChannelConfig, message: TeamsMessage) { + const response = (yield* graphGetUnknown(hostedContentsPath(channel, message)).pipe( + Effect.orElseSucceed(() => ({ value: [] }) satisfies GraphHostedContentResponse), + )) as GraphHostedContentResponse; + return (response.value ?? []) + .filter( + (entry): entry is { readonly id: string; readonly contentType?: string | undefined } => + typeof entry.id === "string" && entry.id.length > 0, + ) + .map((entry) => ({ + id: entry.id, + contentType: entry.contentType, + valueUrl: buildHostedContentValueUrl(channel, message, entry.id), + })); + }, + ); + + const recentHistoryForMessage = ( + channel: TeamsChannelConfig, + messages: ReadonlyArray, + targetMessage: TeamsMessage, + processedRootKeys: ReadonlySet, + ): ReadonlyArray => { + const targetIndex = messages.findIndex((message) => message.id === targetMessage.id); + if (targetIndex <= 0) return []; + + const targetTime = Date.parse(targetMessage.createdDateTime ?? ""); + const history: TeamsMessage[] = []; + for (let index = targetIndex - 1; index >= 0; index -= 1) { + const candidate = messages[index]!; + if (!isHumanTeamsMessage(candidate)) continue; + if (processedRootKeys.has(sourceThreadKey(channel, rootTeamsMessageId(candidate)))) break; + const candidateTime = Date.parse(candidate.createdDateTime ?? ""); + if ( + Number.isFinite(targetTime) && + Number.isFinite(candidateTime) && + targetTime - candidateTime > 2 * 60 * 60 * 1000 + ) { + break; + } + history.push(candidate); + } + + return history.toReversed(); + }; + + const processedRootKeysForChannel = Effect.fn("runTeamsModule.processedRootKeysForChannel")( + function* (channel: TeamsChannelConfig) { + const prefix = `${channel.teamId}/${channel.channelId}/`; + const allLinks = yield* links.list(); + return new Set( + allLinks + .filter((link) => (link.sourceKind ?? "discord") === "teams") + .map((link) => link.sourceThreadId) + .filter( + (value): value is string => typeof value === "string" && value.startsWith(prefix), + ), + ); + }, + ); + + const findMessageById = ( + messages: ReadonlyArray, + messageId: string | null | undefined, + ): TeamsMessage | null => + typeof messageId === "string" + ? (messages.find((message) => message.id === messageId) ?? null) + : null; + + const resolveTagTargetMessage = ( + message: TeamsMessage, + messages: ReadonlyArray, + ): TeamsMessage => findMessageById(messages, message.replyToId) ?? message; + + type TriggerReason = "mention" | "german-problem" | "allowlisted-reaction" | "internal-tag"; + + const classifyTrigger = (input: { + readonly channel: TeamsChannelConfig; + readonly message: TeamsMessage; + readonly messages: ReadonlyArray; + readonly alreadySeen: boolean; + }): { + readonly reason: TriggerReason; + readonly targetMessage: TeamsMessage; + readonly triggerMessage?: TeamsMessage | undefined; + } | null => { + const mentionTrigger = + !input.alreadySeen && mentionsTeamsBot(input.message, config.teamsBotDisplayName); + if (mentionTrigger) { + return { + reason: "mention", + targetMessage: input.message, + }; + } + + const reactionTrigger = hasAllowlistedReaction({ + message: input.message, + allowlistedUserIds: input.channel.internalUserIds, + reactionTriggerTypes: input.channel.reactionTriggerTypes, + }); + if (reactionTrigger) { + return { + reason: "allowlisted-reaction", + targetMessage: input.message, + }; + } + + const tagTrigger = + !input.alreadySeen && + hasInternalTagTrigger({ + message: input.message, + allowlistedUserIds: input.channel.internalUserIds, + messageTagTriggers: input.channel.messageTagTriggers, + }); + if (tagTrigger) { + return { + reason: "internal-tag", + targetMessage: resolveTagTargetMessage(input.message, input.messages), + triggerMessage: input.message, + }; + } + + const automaticAssessmentEnabled = input.channel.automaticAssessmentEnabled ?? true; + const germanProblemTrigger = + automaticAssessmentEnabled && + !input.alreadySeen && + looksLikeGermanProblemReport({ + message: input.message, + companyKeywords: input.channel.companyKeywords, + environmentKeywords: input.channel.environmentKeywords, + problemKeywords: input.channel.problemKeywords, + }); + if (germanProblemTrigger) { + return { + reason: "german-problem", + targetMessage: input.message, + }; + } + + return null; + }; + + const processMessage = Effect.fn("runTeamsModule.processMessage")(function* ( + channel: TeamsChannelConfig, + message: TeamsMessage, + messages: ReadonlyArray, + seenIds: Set, + processedRootKeys: Set, + ) { + if (!isHumanTeamsMessage(message)) return; + + const alreadySeen = seenIds.has(message.id); + const trigger = classifyTrigger({ + channel, + message, + messages, + alreadySeen, + }); + + if (!alreadySeen) { + yield* seenStore.markSeen(channelKey(channel), message.id); + seenIds.add(message.id); + } + + if (trigger === null) return; + + const rootMessageId = rootTeamsMessageId(trigger.targetMessage); + const rootKey = sourceThreadKey(channel, rootMessageId); + if (processedRootKeys.has(rootKey)) return; + + const accessToken = yield* getAccessToken(); + const hostedContents = yield* listHostedContentsForMessage(channel, trigger.targetMessage); + const imageDownloads = yield* Effect.tryPromise({ + try: () => + downloadTeamsMessageImages({ + message: trigger.targetMessage, + accessToken, + hostedContentEntries: hostedContents, + }), + catch: (cause) => new Error(String(cause)), + }).pipe( + Effect.catch((error) => + Effect.logError("Failed to download Teams image attachments", { + error: String(error), + }).pipe( + Effect.as({ + discordFiles: [] as ReadonlyArray, + t3Uploads: [], + skipped: [], + }), + ), + ), + ); + if (imageDownloads.skipped.length > 0) { + yield* Effect.logWarning("Skipped some Teams image attachments", { + skipped: imageDownloads.skipped, + }); + } + + const existing = yield* links.getBySourceThread("teams", rootKey); + const discordThreadId = + existing?.discordThreadId ?? + (yield* openDiscordThread( + channel, + trigger.targetMessage, + trigger.reason, + imageDownloads.discordFiles, + )); + const history = recentHistoryForMessage( + channel, + messages, + trigger.targetMessage, + processedRootKeys, + ); + + const prompt = buildTeamsPrompt({ + channelName: channel.channelName, + company: channel.company, + environment: channel.environment, + projectShortName: channel.projectShortName, + reason: trigger.reason, + message: trigger.targetMessage, + triggerMessage: trigger.triggerMessage, + history, + }); + yield* startOrContinueLinkedTurn(config, { + source: { + sourceKind: "teams", + sourceThreadId: rootKey, + }, + discordThreadId, + discordParentChannelId: channel.discordChannelId, + discordGuildId: "", + projectShortName: channel.projectShortName, + prompt, + flags: {}, + ...(imageDownloads.t3Uploads.length > 0 ? { attachments: imageDownloads.t3Uploads } : {}), + announceLines: [ + `Linked **${channel.projectShortName}**`, + `Source: Teams / ${channel.channelName}`, + `Company: **${channel.company}**`, + `Environment: **${channel.environment}**`, + ], + promptContext: { + kind: "raw", + value: prompt, + }, + }); + processedRootKeys.add(rootKey); + + if (trigger.reason === "mention") { + yield* postTeamsWebhookAck(channel, trigger.targetMessage); + } + }); + + const listMessagesForChannel = Effect.fn("runTeamsModule.listMessagesForChannel")(function* ( + channel: TeamsChannelConfig, + ) { + const lookbackStartMs = nowLookbackStart().getTime(); + const roots = yield* listPagedMessages( + `/teams/${encodeURIComponent(channel.teamId)}/channels/${encodeURIComponent(channel.channelId)}/messages?$top=50`, + lookbackStartMs, + ); + + const collected: TeamsMessage[] = []; + for (const root of roots) { + collected.push(root); + const replies = yield* listPagedMessages( + `/teams/${encodeURIComponent(channel.teamId)}/channels/${encodeURIComponent(channel.channelId)}/messages/${encodeURIComponent(root.id)}/replies?$top=50`, + lookbackStartMs, + ).pipe(Effect.orElseSucceed(() => [] as TeamsMessage[])); + collected.push(...replies); + } + + return collected.toSorted((left, right) => + teamsMessageTimestamp(left).localeCompare(teamsMessageTimestamp(right)), + ); + }); + + yield* Effect.logInfo("Teams module enabled", { + channels: channels.length, + pollIntervalSeconds: config.teamsPollIntervalSeconds, + }); + + while (true) { + for (const channel of channels) { + yield* Effect.gen(function* () { + const messages = yield* listMessagesForChannel(channel); + const seenIds = new Set(yield* seenStore.listSeenIds(channelKey(channel))); + const processedRootKeys = yield* processedRootKeysForChannel(channel); + for (const message of messages) { + yield* processMessage(channel, message, messages, seenIds, processedRootKeys); + } + }).pipe( + Effect.catch((error) => + Effect.logError("Teams channel poll failed", { + teamId: channel.teamId, + channelId: channel.channelId, + error: String(error), + }), + ), + ); + } + + yield* Effect.sleep(Duration.seconds(config.teamsPollIntervalSeconds)); + } +}); diff --git a/apps/discord-bot/src/main.ts b/apps/discord-bot/src/main.ts index 5b92aa1f661..f91953a67de 100644 --- a/apps/discord-bot/src/main.ts +++ b/apps/discord-bot/src/main.ts @@ -15,6 +15,7 @@ import { runAlertWatchdog } from "./features/Alerts.ts"; import { layer as bridgeHubLayer } from "./features/BridgeHub.ts"; import { DiscordBotRunning, MentionRouterLive } from "./features/MentionRouter.ts"; import { runBridge } from "./features/ResponseBridge.ts"; +import { runTeamsModule } from "./features/TeamsModule.ts"; import { backfillThreadInfoPins } from "./features/ThreadInfoPin.ts"; import { rehydrateBridges } from "./features/ThreadRestore.ts"; import { layerFromOptionalPath as identityMapStoreLayer, IdentityMapStore } from "./identityMap.ts"; @@ -22,6 +23,7 @@ import { layerFromOptionalPath as projectAliasStoreLayer, ProjectAliasStore, } from "./projectAliases.ts"; +import { layer as teamsSeenStoreLayer } from "./store/TeamsSeenStore.ts"; import { layer as threadLinkStoreLayer } from "./store/ThreadLinkStore.ts"; import { layer as threadWarmCacheStoreLayer } from "./store/ThreadWarmCacheStore.ts"; import { T3Session, layer as t3SessionLayer } from "./t3/T3Session.ts"; @@ -58,6 +60,7 @@ const MainLayer = Layer.unwrap( const core = Layer.mergeAll( t3SessionLayer(botConfig), threadLinkStoreLayer(botConfig.dataDir), + teamsSeenStoreLayer(botConfig.dataDir), threadWarmCacheStoreLayer(botConfig.dataDir), projectAliasStoreLayer(botConfig.projectAliasesPath), identityMapStoreLayer(botConfig.identityMapPath), @@ -155,6 +158,15 @@ const program = Effect.gen(function* () { }); } + // Optional Teams Graph intake → Discord/T3 (off unless TEAMS_ENABLED=1). + yield* Effect.forkDetach( + runTeamsModule(botConfig).pipe( + Effect.catchCause((cause) => + Effect.logError("Teams module stopped").pipe(Effect.andThen(Effect.logError(cause))), + ), + ), + ); + // Keep process alive; router fibers are scoped to this layer lifetime. return yield* Effect.never; }); diff --git a/apps/discord-bot/src/store/TeamsSeenStore.ts b/apps/discord-bot/src/store/TeamsSeenStore.ts new file mode 100644 index 00000000000..3b5fcf1ec10 --- /dev/null +++ b/apps/discord-bot/src/store/TeamsSeenStore.ts @@ -0,0 +1,87 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off tryCatchInEffectGen:off missingEffectError:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { expandHomePath } from "../projectAliases.ts"; + +export interface TeamsSeenStoreService { + readonly hasSeen: (channelKey: string, messageId: string) => Effect.Effect; + readonly listSeenIds: (channelKey: string) => Effect.Effect>; + readonly markSeen: (channelKey: string, messageId: string) => Effect.Effect; +} + +export class TeamsSeenStore extends Context.Service()( + "@t3tools/discord-bot/store/TeamsSeenStore", +) {} + +const MAX_MESSAGE_IDS_PER_CHANNEL = 500; + +export const layer = (dataDirRaw: string) => + Layer.effect( + TeamsSeenStore, + Effect.gen(function* () { + const dataDir = expandHomePath(dataDirRaw); + const filePath = NodePath.join(dataDir, "teams-seen.json"); + yield* Effect.promise(() => NodeFSP.mkdir(dataDir, { recursive: true, mode: 0o700 })); + + const initial = yield* Effect.tryPromise({ + try: () => NodeFSP.readFile(filePath, "utf8"), + catch: () => null, + }).pipe( + Effect.map((raw) => { + if (raw === null) return {} as Record>; + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return {} as Record>; + } + const entries = Object.entries(parsed as Record).flatMap( + ([channelKey, value]) => + Array.isArray(value) && value.every((item) => typeof item === "string") + ? [[channelKey, value] as const] + : [], + ); + return Object.fromEntries(entries); + }), + Effect.orElseSucceed(() => ({}) as Record>), + ); + + const state = yield* Ref.make( + new Map( + Object.entries(initial).map(([channelKey, messageIds]) => [channelKey, [...messageIds]]), + ), + ); + + const persist = (value: Map) => + Effect.promise(() => + NodeFSP.writeFile( + filePath, + `${JSON.stringify(Object.fromEntries(value.entries()), null, 2)}\n`, + { mode: 0o600 }, + ), + ); + + return TeamsSeenStore.of({ + hasSeen: (channelKey, messageId) => + Ref.get(state).pipe(Effect.map((map) => (map.get(channelKey) ?? []).includes(messageId))), + listSeenIds: (channelKey) => + Ref.get(state).pipe(Effect.map((map) => [...(map.get(channelKey) ?? [])])), + markSeen: (channelKey, messageId) => + Effect.gen(function* () { + const next = yield* Ref.updateAndGet(state, (current) => { + const copy = new Map(current); + const existing = copy.get(channelKey) ?? []; + if (existing.includes(messageId)) { + return copy; + } + copy.set(channelKey, [...existing, messageId].slice(-MAX_MESSAGE_IDS_PER_CHANNEL)); + return copy; + }); + yield* persist(next); + }), + }); + }), + ); diff --git a/apps/discord-bot/src/store/ThreadLinkStore.ts b/apps/discord-bot/src/store/ThreadLinkStore.ts index 658261c21d4..49fa0628428 100644 --- a/apps/discord-bot/src/store/ThreadLinkStore.ts +++ b/apps/discord-bot/src/store/ThreadLinkStore.ts @@ -22,6 +22,10 @@ export type ThreadLinkStatus = typeof ThreadLinkStatus.Type; * and adds restore hints (activity, tombstone, last finalized assistant). */ export const ThreadLink = Schema.Struct({ + /** Originating chat platform; defaults to discord when omitted (older links.json). */ + sourceKind: Schema.optional(Schema.Literals(["discord", "teams"])), + /** Stable external conversation key (Teams root message id, or Discord thread id). */ + sourceThreadId: Schema.optional(Schema.String), discordThreadId: Schema.String, t3ThreadId: Schema.String, projectId: Schema.String, @@ -81,6 +85,8 @@ export const ThreadLink = Schema.Struct({ modelSinceAt: Schema.optional(Schema.String), }); export type ThreadLink = { + readonly sourceKind?: "discord" | "teams" | undefined; + readonly sourceThreadId?: string | undefined; readonly discordThreadId: string; readonly t3ThreadId: ThreadId; readonly projectId: ProjectId; @@ -108,6 +114,8 @@ export type ThreadLink = { /** Fields callers may omit on put — filled with durable defaults. */ export type ThreadLinkInput = { + readonly sourceKind?: "discord" | "teams" | undefined; + readonly sourceThreadId?: string | undefined; readonly discordThreadId: string; readonly t3ThreadId: ThreadId; readonly projectId: ProjectId; @@ -229,6 +237,8 @@ function normalizeStoredPrUrl(raw: string): string | null { } function asThreadLink(link: { + readonly sourceKind?: "discord" | "teams" | undefined; + readonly sourceThreadId?: string | undefined; readonly discordThreadId: string; readonly t3ThreadId: string; readonly projectId: string; @@ -254,6 +264,8 @@ function asThreadLink(link: { readonly modelSinceAt?: string | undefined; }): ThreadLink { return { + sourceKind: link.sourceKind, + sourceThreadId: link.sourceThreadId, discordThreadId: link.discordThreadId, t3ThreadId: link.t3ThreadId as ThreadId, projectId: link.projectId as ProjectId, @@ -329,6 +341,8 @@ export function migrateV1Link(link: { export function normalizeThreadLinkInput(link: ThreadLinkInput): ThreadLink { const createdAt = link.createdAt; return asThreadLink({ + sourceKind: link.sourceKind, + sourceThreadId: link.sourceThreadId, discordThreadId: link.discordThreadId, t3ThreadId: link.t3ThreadId, projectId: link.projectId, @@ -417,6 +431,10 @@ function serializeLinksDocument(links: ReadonlyArray): string { export interface ThreadLinkStoreService { readonly getByDiscordThreadId: (discordThreadId: string) => Effect.Effect; readonly getByT3ThreadId: (t3ThreadId: string) => Effect.Effect; + readonly getBySourceThread: ( + sourceKind: "discord" | "teams", + sourceThreadId: string, + ) => Effect.Effect; readonly put: (link: ThreadLinkInput) => Effect.Effect; readonly touch: (discordThreadId: string, at?: string) => Effect.Effect; readonly tombstone: (discordThreadId: string) => Effect.Effect; @@ -564,6 +582,20 @@ export const makeThreadLinkStore = (dataDirRaw: string) => }), ), + getBySourceThread: (sourceKind, sourceThreadId) => + Ref.get(state).pipe( + Effect.map((map) => { + for (const link of map.values()) { + const linkSourceKind = link.sourceKind ?? "discord"; + const linkSourceThreadId = link.sourceThreadId ?? link.discordThreadId; + if (linkSourceKind === sourceKind && linkSourceThreadId === sourceThreadId) { + return link; + } + } + return null; + }), + ), + put: (link) => writeLock.withPermit( Effect.gen(function* () { @@ -577,6 +609,12 @@ export const makeThreadLinkStore = (dataDirRaw: string) => : asThreadLink({ ...normalized, // Never wipe durable hints on a minimal re-put (overlapping writers). + sourceKind: + link.sourceKind !== undefined ? normalized.sourceKind : existing.sourceKind, + sourceThreadId: + link.sourceThreadId !== undefined + ? normalized.sourceThreadId + : existing.sourceThreadId, lastFinalizedAssistantId: link.lastFinalizedAssistantId !== undefined ? normalized.lastFinalizedAssistantId diff --git a/apps/discord-bot/src/teams/attachments.ts b/apps/discord-bot/src/teams/attachments.ts new file mode 100644 index 00000000000..f5d9de9b3a1 --- /dev/null +++ b/apps/discord-bot/src/teams/attachments.ts @@ -0,0 +1,147 @@ +// @effect-diagnostics globalFetch:off +import type { UploadChatAttachment } from "@t3tools/contracts"; +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; + +import type { DiscordUploadFile } from "../presentation/discordFiles.ts"; +import type { TeamsMessage } from "./presentation.ts"; + +export interface TeamsImageDownloadResult { + readonly discordFiles: ReadonlyArray; + readonly t3Uploads: ReadonlyArray; + readonly skipped: ReadonlyArray<{ + readonly name: string; + readonly reason: string; + }>; +} + +function extensionFromName(name: string): string { + const match = /\.([a-z0-9]+)$/iu.exec(name.trim()); + return match?.[1]?.toLowerCase() ?? ""; +} + +function guessImageMimeType(input: { + readonly name: string; + readonly contentType?: string | undefined; +}): string | null { + const contentType = input.contentType?.trim().toLowerCase(); + if (contentType?.startsWith("image/")) return contentType; + + return ( + { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + bmp: "image/bmp", + svg: "image/svg+xml", + heic: "image/heic", + heif: "image/heif", + }[extensionFromName(input.name)] ?? null + ); +} + +function base64DataUrl(bytes: Uint8Array, mimeType: string): string { + return `data:${mimeType};base64,${Buffer.from(bytes).toString("base64")}`; +} + +async function fetchBytes(input: { + readonly url: string; + readonly accessToken?: string | undefined; +}): Promise { + const withAuth = input.accessToken + ? ({ + authorization: `Bearer ${input.accessToken}`, + } satisfies HeadersInit) + : undefined; + const response = await globalThis.fetch(input.url, withAuth ? { headers: withAuth } : undefined); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return new Uint8Array(await response.arrayBuffer()); +} + +export async function downloadTeamsMessageImages(input: { + readonly message: TeamsMessage; + readonly accessToken: string; + readonly hostedContentEntries: ReadonlyArray<{ + readonly id: string; + readonly contentType?: string | undefined; + readonly valueUrl: string; + }>; +}): Promise { + const discordFiles: DiscordUploadFile[] = []; + const t3Uploads: UploadChatAttachment[] = []; + const skipped: Array<{ readonly name: string; readonly reason: string }> = []; + + const pushImage = (name: string, mimeType: string, bytes: Uint8Array) => { + discordFiles.push({ + name, + mimeType, + data: bytes, + }); + if (bytes.byteLength <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + t3Uploads.push({ + type: "image", + name, + mimeType, + sizeBytes: bytes.byteLength, + dataUrl: base64DataUrl(bytes, mimeType), + }); + } else { + skipped.push({ + name, + reason: `too large for T3 upload (${bytes.byteLength} bytes)`, + }); + } + }; + + for (const attachment of input.message.attachments ?? []) { + const name = attachment.name?.trim() || "teams-image"; + const mimeType = guessImageMimeType({ + name, + contentType: attachment.contentType, + }); + const url = attachment.contentUrl ?? attachment.thumbnailUrl ?? ""; + if (mimeType === null || url.trim() === "") continue; + + try { + const bytes = await fetchBytes({ + url, + accessToken: input.accessToken, + }); + pushImage(name, mimeType, bytes); + } catch (error) { + skipped.push({ + name, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + for (const hostedContent of input.hostedContentEntries) { + const mimeType = hostedContent.contentType?.trim().toLowerCase(); + if (!mimeType?.startsWith("image/")) continue; + + const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9]+/giu, "") || "png"; + const name = `teams-inline-${hostedContent.id}.${extension}`; + try { + const bytes = await fetchBytes({ + url: hostedContent.valueUrl, + accessToken: input.accessToken, + }); + pushImage(name, mimeType, bytes); + } catch (error) { + skipped.push({ + name, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + return { + discordFiles, + t3Uploads, + skipped, + }; +} diff --git a/apps/discord-bot/src/teams/config.test.ts b/apps/discord-bot/src/teams/config.test.ts new file mode 100644 index 00000000000..d36fb02aae2 --- /dev/null +++ b/apps/discord-bot/src/teams/config.test.ts @@ -0,0 +1,52 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { loadTeamsChannelConfigsFromFileSync } from "./config.ts"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => NodeFSP.rm(dir, { recursive: true, force: true })), + ); +}); + +describe("loadTeamsChannelConfigsFromFileSync", () => { + it("loads root channels arrays", async () => { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "teams-config-")); + tempDirs.push(dir); + const filePath = NodePath.join(dir, "teams.json"); + await NodeFSP.writeFile( + filePath, + JSON.stringify([ + { + teamId: "team-1", + channelId: "channel-1", + channelName: "Prod", + projectShortName: "MACS-SCANNER", + discordChannelId: "123", + company: "Acme", + environment: "prod", + companyKeywords: ["acme"], + environmentKeywords: ["prod"], + automaticAssessmentEnabled: false, + internalUserIds: [" user-1 ", "user-2"], + reactionTriggerTypes: [" Eyes ", "🚨"], + messageTagTriggers: [" #Investigate ", "#triage"], + }, + ]), + "utf8", + ); + + const configs = loadTeamsChannelConfigsFromFileSync(filePath); + expect(configs).toHaveLength(1); + expect(configs[0]?.projectShortName).toBe("macs-scanner"); + expect(configs[0]?.automaticAssessmentEnabled).toBe(false); + expect(configs[0]?.internalUserIds).toEqual(["user-1", "user-2"]); + expect(configs[0]?.reactionTriggerTypes).toEqual(["eyes", "🚨"]); + expect(configs[0]?.messageTagTriggers).toEqual(["#investigate", "#triage"]); + }); +}); diff --git a/apps/discord-bot/src/teams/config.ts b/apps/discord-bot/src/teams/config.ts new file mode 100644 index 00000000000..f96a2734dc1 --- /dev/null +++ b/apps/discord-bot/src/teams/config.ts @@ -0,0 +1,116 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off tryCatchInEffectGen:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { expandHomePath } from "../projectAliases.ts"; + +export interface TeamsChannelConfig { + readonly teamId: string; + readonly channelId: string; + readonly channelName: string; + readonly projectShortName: string; + readonly discordChannelId: string; + readonly company: string; + readonly environment: string; + readonly companyKeywords: ReadonlyArray; + readonly environmentKeywords: ReadonlyArray; + readonly problemKeywords?: ReadonlyArray | undefined; + readonly automaticAssessmentEnabled?: boolean | undefined; + readonly internalUserIds?: ReadonlyArray | undefined; + readonly reactionTriggerTypes?: ReadonlyArray | undefined; + readonly messageTagTriggers?: ReadonlyArray | undefined; + readonly respondToMentions?: boolean | undefined; + readonly teamsIncomingWebhookUrl?: string | undefined; +} + +function isStringArray(value: unknown): value is ReadonlyArray { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function isChannelConfig(value: unknown): value is TeamsChannelConfig { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const candidate = value as Record; + return ( + typeof candidate.teamId === "string" && + typeof candidate.channelId === "string" && + typeof candidate.channelName === "string" && + typeof candidate.projectShortName === "string" && + typeof candidate.discordChannelId === "string" && + typeof candidate.company === "string" && + typeof candidate.environment === "string" && + isStringArray(candidate.companyKeywords) && + isStringArray(candidate.environmentKeywords) && + (candidate.problemKeywords === undefined || isStringArray(candidate.problemKeywords)) && + (candidate.automaticAssessmentEnabled === undefined || + typeof candidate.automaticAssessmentEnabled === "boolean") && + (candidate.internalUserIds === undefined || isStringArray(candidate.internalUserIds)) && + (candidate.reactionTriggerTypes === undefined || + isStringArray(candidate.reactionTriggerTypes)) && + (candidate.messageTagTriggers === undefined || isStringArray(candidate.messageTagTriggers)) && + (candidate.respondToMentions === undefined || + typeof candidate.respondToMentions === "boolean") && + (candidate.teamsIncomingWebhookUrl === undefined || + typeof candidate.teamsIncomingWebhookUrl === "string") + ); +} + +export function loadTeamsChannelConfigsFromFileSync( + filePath: string, +): ReadonlyArray { + const resolvedPath = NodePath.resolve(expandHomePath(filePath.trim())); + const raw = NodeFS.readFileSync(resolvedPath, "utf8").trim(); + if (raw.length === 0) return []; + + const parsed = JSON.parse(raw) as unknown; + const channels = Array.isArray(parsed) + ? parsed + : typeof parsed === "object" && + parsed !== null && + "channels" in parsed && + Array.isArray((parsed as { channels?: unknown }).channels) + ? (parsed as { channels: ReadonlyArray }).channels + : null; + if (channels === null || !channels.every(isChannelConfig)) { + throw new Error("Teams channel config file must be an array of valid channel objects."); + } + + return channels.map((channel) => ({ + ...channel, + projectShortName: channel.projectShortName.trim().toLowerCase(), + companyKeywords: channel.companyKeywords.map((value: string) => value.trim()).filter(Boolean), + environmentKeywords: channel.environmentKeywords + .map((value: string) => value.trim()) + .filter(Boolean), + ...(channel.problemKeywords === undefined + ? {} + : { + problemKeywords: channel.problemKeywords + .map((value: string) => value.trim()) + .filter(Boolean), + }), + ...(channel.automaticAssessmentEnabled === undefined + ? {} + : { automaticAssessmentEnabled: channel.automaticAssessmentEnabled }), + ...(channel.internalUserIds === undefined + ? {} + : { + internalUserIds: channel.internalUserIds + .map((value: string) => value.trim()) + .filter(Boolean), + }), + ...(channel.reactionTriggerTypes === undefined + ? {} + : { + reactionTriggerTypes: channel.reactionTriggerTypes + .map((value: string) => value.trim().toLowerCase()) + .filter(Boolean), + }), + ...(channel.messageTagTriggers === undefined + ? {} + : { + messageTagTriggers: channel.messageTagTriggers + .map((value: string) => value.trim().toLowerCase()) + .filter(Boolean), + }), + })); +} diff --git a/apps/discord-bot/src/teams/presentation.test.ts b/apps/discord-bot/src/teams/presentation.test.ts new file mode 100644 index 00000000000..9bd37b0f710 --- /dev/null +++ b/apps/discord-bot/src/teams/presentation.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + hasAllowlistedReaction, + hasInternalTagTrigger, + looksLikeGermanProblemReport, + teamsMessageText, +} from "./presentation.ts"; + +describe("teamsMessageText", () => { + it("strips html", () => { + expect( + teamsMessageText({ + id: "1", + body: { content: "
Hallo Welt & mehr
" }, + }), + ).toBe("Hallo Welt & mehr"); + }); +}); + +describe("looksLikeGermanProblemReport", () => { + it("matches german production incidents", () => { + expect( + looksLikeGermanProblemReport({ + message: { + id: "1", + body: { content: "Beim Kunden Acme gibt es seit heute einen Fehler in Produktion." }, + }, + companyKeywords: ["Acme"], + environmentKeywords: ["Produktion"], + }), + ).toBe(true); + }); +}); + +describe("hasAllowlistedReaction", () => { + it("matches configured reactions from allowlisted users", () => { + expect( + hasAllowlistedReaction({ + message: { + id: "1", + reactions: [ + { + reactionType: "eyes", + user: { + user: { + id: "internal-user", + }, + }, + }, + ], + }, + allowlistedUserIds: ["internal-user"], + reactionTriggerTypes: ["eyes"], + }), + ).toBe(true); + }); +}); + +describe("hasInternalTagTrigger", () => { + it("matches tag commands from allowlisted users", () => { + expect( + hasInternalTagTrigger({ + message: { + id: "1", + from: { + user: { + id: "internal-user", + }, + }, + body: { + content: "Please take this one #investigate", + }, + }, + allowlistedUserIds: ["internal-user"], + messageTagTriggers: ["#investigate"], + }), + ).toBe(true); + }); +}); diff --git a/apps/discord-bot/src/teams/presentation.ts b/apps/discord-bot/src/teams/presentation.ts new file mode 100644 index 00000000000..0b559d23011 --- /dev/null +++ b/apps/discord-bot/src/teams/presentation.ts @@ -0,0 +1,338 @@ +export interface TeamsMessageAuthor { + readonly id?: string | undefined; + readonly displayName?: string | undefined; +} + +export interface TeamsMessage { + readonly id: string; + readonly replyToId?: string | null | undefined; + readonly createdDateTime?: string | undefined; + readonly subject?: string | null | undefined; + readonly summary?: string | null | undefined; + readonly webUrl?: string | undefined; + readonly messageType?: string | undefined; + readonly from?: + | { + readonly user?: TeamsMessageAuthor | null | undefined; + readonly application?: { readonly displayName?: string | undefined } | null | undefined; + } + | null + | undefined; + readonly body?: + | { + readonly contentType?: string | undefined; + readonly content?: string | null | undefined; + } + | null + | undefined; + readonly attachments?: + | ReadonlyArray<{ + readonly id?: string | undefined; + readonly contentType?: string | undefined; + readonly contentUrl?: string | undefined; + readonly name?: string | undefined; + readonly thumbnailUrl?: string | undefined; + }> + | undefined; + readonly reactions?: + | ReadonlyArray<{ + readonly reactionType?: string | undefined; + readonly displayName?: string | undefined; + readonly user?: + | { + readonly user?: + | { + readonly id?: string | undefined; + readonly displayName?: string | undefined; + } + | null + | undefined; + } + | null + | undefined; + }> + | undefined; + readonly mentions?: + | ReadonlyArray<{ + readonly mentionText?: string | undefined; + readonly mentioned?: + | { + readonly user?: TeamsMessageAuthor | null | undefined; + readonly application?: + | { readonly displayName?: string | undefined } + | null + | undefined; + } + | null + | undefined; + }> + | undefined; +} + +const DEFAULT_GERMAN_PROBLEM_KEYWORDS = [ + "fehler", + "störung", + "stoerung", + "problem", + "kaputt", + "funktioniert nicht", + "geht nicht", + "ausfall", + "dringend", + "hilfe", + "betroffen", + "unterbrochen", + "broken", + "incident", +] as const; + +const GERMAN_MARKERS = [ + "der", + "die", + "das", + "nicht", + "bitte", + "kann", + "können", + "koennen", + "seit", + "heute", + "gestern", + "kunde", + "umgebung", + "produktion", +] as const; + +export function teamsMessageText(message: TeamsMessage): string { + const content = message.body?.content ?? ""; + return decodeHtmlEntities( + content + .replace(//giu, " ") + .replace(//giu, " ") + .replace(/<[^>]+>/gu, " ") + .replace(/\s+/gu, " ") + .trim(), + ) + .replace(/\s+/gu, " ") + .trim(); +} + +function normalizedText(value: string | undefined | null): string { + return (value ?? "").trim().toLowerCase(); +} + +function decodeHtmlEntities(value: string): string { + return value + .replaceAll(" ", " ") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'"); +} + +export function rootTeamsMessageId(message: TeamsMessage): string { + return message.replyToId ?? message.id; +} + +export function teamsMessageTimestamp(message: TeamsMessage): string { + return message.createdDateTime ?? ""; +} + +export function isHumanTeamsMessage(message: TeamsMessage): boolean { + if (message.messageType && message.messageType !== "message") return false; + if (message.from?.application) return false; + return teamsMessageText(message).trim().length > 0; +} + +export function mentionsTeamsBot(message: TeamsMessage, displayName: string | undefined): boolean { + const normalizedDisplayName = normalizedText(displayName); + if ( + normalizedDisplayName.length > 0 && + message.mentions?.some((mention) => { + const names = [ + mention.mentionText, + mention.mentioned?.application?.displayName, + mention.mentioned?.user?.displayName, + ] + .filter((value): value is string => typeof value === "string") + .map((value) => normalizedText(value)); + return names.includes(normalizedDisplayName); + }) + ) { + return true; + } + + const text = normalizedText(teamsMessageText(message)); + return normalizedDisplayName.length > 0 ? text.includes(normalizedDisplayName) : false; +} + +export function looksLikeGermanProblemReport(input: { + readonly message: TeamsMessage; + readonly companyKeywords: ReadonlyArray; + readonly environmentKeywords: ReadonlyArray; + readonly problemKeywords?: ReadonlyArray | undefined; +}): boolean { + const text = teamsMessageText(input.message).toLowerCase(); + if (text.length === 0) return false; + + const germanMarkerHits = GERMAN_MARKERS.filter((marker) => text.includes(marker)).length; + const umlautHint = /[äöüß]/iu.test(text); + const germanish = umlautHint || germanMarkerHits >= 2; + if (!germanish) return false; + + const companyHit = input.companyKeywords.some((keyword) => text.includes(keyword.toLowerCase())); + const environmentHit = input.environmentKeywords.some((keyword) => + text.includes(keyword.toLowerCase()), + ); + const problemHit = [...DEFAULT_GERMAN_PROBLEM_KEYWORDS, ...(input.problemKeywords ?? [])].some( + (keyword) => text.includes(keyword.toLowerCase()), + ); + + return problemHit && (companyHit || environmentHit); +} + +export function isAllowlistedInternalUser( + message: TeamsMessage, + allowlistedUserIds: ReadonlyArray | undefined, +): boolean { + const authorId = normalizedText(message.from?.user?.id); + return ( + authorId.length > 0 && (allowlistedUserIds ?? []).some((id) => normalizedText(id) === authorId) + ); +} + +export function hasAllowlistedReaction(input: { + readonly message: TeamsMessage; + readonly allowlistedUserIds?: ReadonlyArray | undefined; + readonly reactionTriggerTypes?: ReadonlyArray | undefined; +}): boolean { + if ((input.reactionTriggerTypes ?? []).length === 0) return false; + + return (input.message.reactions ?? []).some((reaction) => { + const reactionType = normalizedText(reaction.reactionType); + const reactingUserId = normalizedText(reaction.user?.user?.id); + if (reactionType.length === 0 || reactingUserId.length === 0) return false; + const typeMatch = (input.reactionTriggerTypes ?? []).some( + (trigger) => normalizedText(trigger) === reactionType, + ); + const userMatch = (input.allowlistedUserIds ?? []).some( + (userId) => normalizedText(userId) === reactingUserId, + ); + return typeMatch && userMatch; + }); +} + +export function hasInternalTagTrigger(input: { + readonly message: TeamsMessage; + readonly allowlistedUserIds?: ReadonlyArray | undefined; + readonly messageTagTriggers?: ReadonlyArray | undefined; +}): boolean { + if (!isAllowlistedInternalUser(input.message, input.allowlistedUserIds)) return false; + const text = normalizedText(teamsMessageText(input.message)); + return (input.messageTagTriggers ?? []).some((tag) => { + const normalizedTag = normalizedText(tag); + return normalizedTag.length > 0 && text.includes(normalizedTag); + }); +} + +export function buildTeamsIncidentTitle(input: { + readonly company: string; + readonly environment: string; + readonly message: TeamsMessage; +}): string { + const text = teamsMessageText(input.message).replace(/\s+/gu, " ").trim(); + const summary = text.length > 96 ? `${text.slice(0, 95).trimEnd()}…` : text; + return `${input.company} ${input.environment}: ${summary || "Teams incident"}`; +} + +export function buildTeamsSeedMessage(input: { + readonly company: string; + readonly environment: string; + readonly channelName: string; + readonly message: TeamsMessage; + readonly reason: "mention" | "german-problem" | "allowlisted-reaction" | "internal-tag"; +}): string { + const author = input.message.from?.user?.displayName ?? "unknown"; + const text = teamsMessageText(input.message); + const triggerLabel = + input.reason === "mention" + ? "@mention" + : input.reason === "german-problem" + ? "German problem report" + : input.reason === "allowlisted-reaction" + ? "allowlisted reaction" + : "allowlisted internal tag"; + return [ + `**Teams intake** from **${input.channelName}**`, + `Company: **${input.company}**`, + `Environment: **${input.environment}**`, + `Trigger: ${triggerLabel}`, + `Author: ${author}`, + input.message.webUrl ? `Source: ${input.message.webUrl}` : null, + "", + text, + ] + .filter((line): line is string => line !== null) + .join("\n"); +} + +export function buildTeamsPrompt(input: { + readonly channelName: string; + readonly company: string; + readonly environment: string; + readonly projectShortName: string; + readonly reason: "mention" | "german-problem" | "allowlisted-reaction" | "internal-tag"; + readonly message: TeamsMessage; + readonly triggerMessage?: TeamsMessage | undefined; + readonly history?: ReadonlyArray | undefined; +}): string { + const text = teamsMessageText(input.message); + const author = input.message.from?.user?.displayName ?? "unknown"; + const triggerText = + input.reason === "mention" + ? "explicit @mention" + : input.reason === "german-problem" + ? "German problem report detection" + : input.reason === "allowlisted-reaction" + ? "allowlisted reaction trigger" + : "allowlisted internal tag trigger"; + const history = (input.history ?? []) + .map((message) => { + const entryAuthor = message.from?.user?.displayName ?? "unknown"; + const when = message.createdDateTime ?? "unknown time"; + const body = teamsMessageText(message); + return `[${when}] ${entryAuthor}: ${body}`; + }) + .join("\n"); + + return `## Microsoft Teams intake + +Project short name: ${input.projectShortName} +Company: ${input.company} +Environment: ${input.environment} +Teams channel: ${input.channelName} +Trigger: ${triggerText} +Author: ${author} +Message id: ${input.message.id} +${input.message.replyToId ? `Reply to: ${input.message.replyToId}` : "Root message: yes"} +${input.message.webUrl ? `Source URL: ${input.message.webUrl}` : "Source URL: unavailable"} + +${ + input.triggerMessage && input.triggerMessage.id !== input.message.id + ? `Trigger message id: ${input.triggerMessage.id} +Trigger message author: ${input.triggerMessage.from?.user?.displayName ?? "unknown"} +Trigger message text: ${teamsMessageText(input.triggerMessage)}` + : "" +} + +## Original Teams message +${text} + +## Recent channel history +${history.length > 0 ? history : "(no recent preceding messages in scope)"} + +## Task +Treat this as an incident intake from Teams. Infer the likely user problem from the German text, investigate it, and post the analysis into this Discord thread. +If details are ambiguous, say so explicitly instead of inventing specifics.`; +} diff --git a/apps/discord-bot/teams.channels.example.json b/apps/discord-bot/teams.channels.example.json new file mode 100644 index 00000000000..f2fe89cb32e --- /dev/null +++ b/apps/discord-bot/teams.channels.example.json @@ -0,0 +1,22 @@ +{ + "channels": [ + { + "teamId": "00000000-0000-0000-0000-000000000000", + "channelId": "19:examplechannel@thread.tacv2", + "channelName": "Acme Prod Incidents", + "projectShortName": "macs-scanner", + "discordChannelId": "123456789012345678", + "company": "Acme", + "environment": "prod", + "companyKeywords": ["acme", "kunde acme"], + "environmentKeywords": ["prod", "produktion", "live"], + "problemKeywords": ["fehler", "störung", "ausfall", "geht nicht"], + "automaticAssessmentEnabled": true, + "internalUserIds": ["11111111-1111-1111-1111-111111111111"], + "reactionTriggerTypes": ["eyes", "🚨"], + "messageTagTriggers": ["#investigate", "#triage"], + "respondToMentions": true, + "teamsIncomingWebhookUrl": "https://example.webhook.office.com/webhookb2/..." + } + ] +} diff --git a/docs/integrations/chat-platform-adapters.md b/docs/integrations/chat-platform-adapters.md index 08d0369da8c..849c4695f0b 100644 --- a/docs/integrations/chat-platform-adapters.md +++ b/docs/integrations/chat-platform-adapters.md @@ -373,9 +373,23 @@ That PR should: - convert storage and rendering boundaries to platform-neutral interfaces - leave the repo in a state where a Teams adapter can be added without reworking the Discord app again +## Related work already in this branch + +- **Teams Graph intake module** (ported from aaaomega/t3code-pvt#1): + `apps/discord-bot/src/features/TeamsModule.ts`, + `docs/integrations/microsoft-teams-discord-bot.md` +- **Teams message-action plan** (ported from aaaomega/t3code-pvt#6): + `docs/integrations/microsoft-teams-message-action-plan.md` +- Shared start/continue helper for Discord + Teams intake: + `apps/discord-bot/src/features/LinkedTurnRouter.ts` + +The intake module is a near-term path that escalates Teams traffic into Discord/T3 without waiting for a full Bot Framework adapter. The message-action plan covers the preferred long-term low-noise trigger model. Both feed into the phased adapter extraction above. + ## References - Current Discord integration: `docs/integrations/discord-bot.md` +- Teams intake module: `docs/integrations/microsoft-teams-discord-bot.md` +- Teams message actions plan: `docs/integrations/microsoft-teams-message-action-plan.md` - Current Discord router: `apps/discord-bot/src/features/MentionRouter.ts` - Current bridge implementation: `apps/discord-bot/src/features/ResponseBridge.ts` - Current T3 transport layer: `apps/discord-bot/src/t3/T3Session.ts` diff --git a/docs/integrations/microsoft-teams-discord-bot.md b/docs/integrations/microsoft-teams-discord-bot.md new file mode 100644 index 00000000000..faadaa9743a --- /dev/null +++ b/docs/integrations/microsoft-teams-discord-bot.md @@ -0,0 +1,162 @@ +# Microsoft Teams Intake Module + +This repository now includes a Teams intake path inside [`apps/discord-bot`](apps/discord-bot). + +It does three things: + +1. Polls a configured list of Teams channels through Microsoft Graph. +2. Detects one or more configured trigger modes per channel. +3. Creates or reuses a Discord thread, starts a T3 investigation thread, and streams the analysis back into Discord. + +## What The Module Does + +- Each Teams channel is configured with: + - the Teams `teamId` and `channelId` + - the target Discord channel id + - the T3 project short name + - company and environment labels + - company/environment/problem keywords for matching +- Each channel can enable any combination of these escalation modes: + - automatic assessment of new messages for German problem reports + - allowlisted internal-user reactions, such as `eyes` or `🚨` + - allowlisted internal-user tag messages, such as `#investigate`, typically sent as a reply to the message being escalated +- Explicit Teams `@mention` traffic is treated as a forced trigger on top of those modes. +- Automatic assessment only runs on newly seen messages. +- Reaction triggers are re-evaluated on already-seen messages, so an internal user can flag an older message later without reposting it. +- Tag triggers are only accepted from configured internal user ids on a per-channel basis. +- The first detected message for a Teams thread opens a Discord thread under the mapped Discord channel. +- Later matching messages in the same Teams thread continue the same T3 thread instead of opening duplicates. +- Any image attachments on the target Teams message are copied into the Discord seed message and passed into the T3 investigation turn. +- The intake prompt also includes immediate preceding channel history from the prior two hours, stopping at the previous already-investigated Teams root. + +## Trigger Configuration + +Per-channel configuration supports these fields: + +- `automaticAssessmentEnabled` + - Default: `true` + - Enables German problem-report assessment for newly seen messages in that channel. +- `internalUserIds` + - Microsoft Entra user ids allowed to trigger manual escalations in that channel. +- `reactionTriggerTypes` + - Reaction types accepted from `internalUserIds`, for example `["eyes", "🚨"]`. +- `messageTagTriggers` + - Text markers accepted from `internalUserIds`, for example `["#investigate", "#triage"]`. + +You can enable one, two, or all three of those modes per channel. + +## Current Limits + +- The implementation uses Microsoft Graph application auth for reading channel messages. +- For write-back to Teams, the implementation only supports an optional channel webhook acknowledgement. +- It does **not** implement a full Bot Framework HTTPS endpoint yet. +- Because of that, “respond to @ trigger messages” currently means: + - detect the mention + - create the Discord/T3 incident thread + - optionally post a short acknowledgement back to Teams through a configured incoming webhook +- If you need native threaded replies in Teams as the bot, add a Bot Framework endpoint later. + +## Config Files + +Set these env vars for the Discord bot process: + +- `TEAMS_ENABLED=1` +- `TEAMS_TENANT_ID=` +- `TEAMS_CLIENT_ID=` +- `TEAMS_CLIENT_SECRET=` +- `TEAMS_CHANNELS_PATH=/absolute/path/to/teams.channels.json` +- `TEAMS_POLL_INTERVAL_SECONDS=60` +- `TEAMS_BOT_DISPLAY_NAME=T3 Code` + +Use [`apps/discord-bot/teams.channels.example.json`](apps/discord-bot/teams.channels.example.json) as the starting point. + +## Polling Window + +- On normal weekdays, the poller scans the last 24 hours of channel traffic. +- On Saturday, Sunday, and Monday, it scans from Friday 00:00 UTC onward so weekend reports are still in scope on Monday morning. + +## Microsoft Setup + +The setup depends on whether you only need scan-and-escalate, or also want native in-Teams bot replies. + +### Minimum Setup For This Module + +This is enough for the code currently in the repo. + +1. Create a Microsoft Entra app registration. +2. Add a client secret. +3. Grant Microsoft Graph application permissions needed to read channel messages. +4. Grant admin consent in the tenant. +5. Install the Teams app where you want resource-specific consent, if you use RSC-scoped access. +6. Create one Teams incoming webhook per channel only if you want acknowledgements posted back to Teams. + +### Permissions And Consent + +From Microsoft Learn: + +- Teams bots only receive channel messages by default when directly mentioned. +- To receive all channel messages without mentions, Teams supports resource-specific consent with `ChannelMessage.Read.Group`. +- Microsoft Graph permissions that expose organization-wide data require admin consent in Microsoft Entra. +- Teams app admins can review both Microsoft Graph permissions and RSC permissions in the Teams admin center. + +Operationally, that means: + +- If you want least-privilege per team/channel behavior, use Teams app installation plus RSC. +- If your tenant instead grants broader Graph application permissions, your security review should treat that as tenant-wide read access. + +## Recommended Org Approval Path + +1. Security reviews the Entra app registration and the exact Graph/RSC permissions requested. +2. Teams admins verify the app in Teams admin center and review the Permissions tab. +3. Team owners approve installation into the specific teams/channels that should be monitored. +4. Discord admins provide the destination Discord channel ids. +5. Operators add the channel mappings JSON and restart the bot. + +## FAQ: Can We Reuse One Team Member's Credentials? + +Technically, yes, for very basic delegated-access scenarios. In that model the bot acts on behalf of a real user instead of using app-only service credentials. + +That is not the recommended setup for this module. + +Main downsides: + +- Reliability: + - the integration breaks if that user changes password, loses access, leaves the company, or is affected by MFA or Conditional Access changes +- Security: + - the bot now depends on a human credential or long-lived delegated refresh token, which is a worse secret to protect than a service credential +- Auditability: + - reads and writes are attributed to a person rather than a service identity +- Access control: + - the effective scope becomes whatever that user can access, which is often broader and less explicit than a dedicated app registration +- Operability: + - Microsoft discourages username/password automation flows such as ROPC, and those flows are incompatible with common MFA-based tenant setups + +Use a real Entra app registration with app-only auth for the poller. + +If you later need a user-driven trigger that should feel native inside Teams, use a proper Teams app or message action flow instead of storing one employee's credentials in the bot. + +## Native Teams Bot Replies + +If you later need true bot replies inside Teams threads, you will need more than this module currently ships: + +1. A Teams app manifest with bot scope for teams/channels. +2. A Bot Framework or Teams AI endpoint reachable from Microsoft over HTTPS. +3. Bot authentication and token validation for incoming activities. +4. Channel installation in each target team/channel. + +That is a separate step because it changes the runtime shape from “poller” to “internet-facing bot service”. + +## Microsoft References + +- Receive all channel messages for bots and agents: + https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-messages-for-bots-and-agents +- Channel and group chat conversations with a bot: + https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations +- Resource-specific consent: + https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/resource-specific-consent +- Teams app permissions and consent: + https://learn.microsoft.com/en-us/microsoftteams/app-permissions +- Apps for shared and private channels: + https://learn.microsoft.com/en-us/microsoftteams/platform/build-apps-for-shared-private-channels +- Microsoft Graph permissions reference: + https://learn.microsoft.com/en-us/graph/permissions-reference diff --git a/docs/integrations/microsoft-teams-message-action-plan.md b/docs/integrations/microsoft-teams-message-action-plan.md new file mode 100644 index 00000000000..b374398a4d5 --- /dev/null +++ b/docs/integrations/microsoft-teams-message-action-plan.md @@ -0,0 +1,186 @@ +# Microsoft Teams Message Action Plan + +This document describes the next step after the polling-based Teams intake module: a native Teams message action that lets an internal user trigger investigation from the message UI instead of relying on visible channel tags. + +It is intended to stack on top of the polling implementation in [`docs/integrations/microsoft-teams-discord-bot.md`](/var/lib/t3/worktrees/t3code/t3-discord-87fc50be/docs/integrations/microsoft-teams-discord-bot.md). + +## Why This Mode + +The current poller already supports three low-friction triggers: + +- automatic German problem detection +- allowlisted internal-user reactions +- allowlisted internal-user tag messages + +Those are good short-term options, but a native Teams message action is a better end state because it: + +- avoids noisy visible tags in the channel +- keeps the trigger close to the source message +- makes authorization easier to explain to internal users +- gives us room for a confirmation dialog before opening a Discord/T3 investigation + +## Recommended Product Shape + +Use a bot-based Teams message extension with an action command available in the `message` context. + +Recommended command name: + +- `Start investigation` + +Optional second command later: + +- `Start investigation with note` + +Why bot-based: + +- Teams action commands for message actions are supported through bot-based message extensions. +- This keeps us compatible with the existing bot-style workflow and gives us room for richer dialog handling later. + +## User Flow + +1. An internal user opens the message action menu on a Teams message. +2. The user selects `Start investigation`. +3. Phase 1 can immediately submit the message payload without a form. +4. The bot service validates the user and channel against our allowlists. +5. The service resolves the source message, recent history, and image attachments. +6. The existing Teams-to-Discord intake pipeline is invoked with reason `message-action`. +7. Teams returns a lightweight confirmation to the triggering user. +8. Discord receives or reuses the linked thread and starts the T3 investigation. + +## Architecture + +Add a small internet-facing Teams bot service next to the current poller. + +Core pieces: + +- Teams app manifest with: + - `bot` + - `composeExtensions` + - action command in `message` context +- Microsoft Bot registration with Teams channel enabled +- HTTPS endpoint that receives Bot Framework invoke activities for the message extension +- shared intake service that both the poller and the message action path call + +Recommended internal split: + +- Keep Graph polling in the current module. +- Extract intake orchestration into a shared service, for example `TeamsIntakeRouter`. +- Let both: + - the poller + - the message action webhook + call the same `start investigation` path. + +That avoids duplicating: + +- Discord thread lookup/creation +- attachment copying +- history collection +- dedupe rules +- T3 turn startup + +## Auth And Permissions + +### Teams App / Bot + +Required: + +- Microsoft Entra app registration for the Teams app/bot identity +- Bot Framework registration +- Teams channel enabled for the bot +- public HTTPS endpoint with Bot Framework auth/token validation + +### Graph Access + +The message action payload gives message context, but the service should still fetch canonical message data through Microsoft Graph before escalation so that it can: + +- pull image attachments and hosted content +- collect recent history +- apply channel configuration and dedupe rules consistently + +This means the message action path still depends on Graph application permissions or resource-specific consent, the same way the poller does. + +### Allowlisting + +The server should enforce all of these before starting an investigation: + +- triggering Teams user id is allowlisted for that channel +- target Teams channel is configured +- source message root has not already been processed + +## Organizational Approval + +Compared with the current poller-only setup, the message action path adds a second approval surface: + +1. Security review for the Entra app registration and Graph permissions. +2. Teams admin review for the app manifest, bot capability, and message extension capability. +3. Bot service review because the app now exposes a public HTTPS endpoint. +4. Team-owner rollout approval for installation into the specific teams/channels. + +Operationally, the main extra review item is that this is no longer just a background poller. It becomes an interactive Teams app with an externally reachable bot endpoint. + +## Proposed Delivery Phases + +### Phase 1 + +Goal: single-click message action without extra dialog fields. + +Scope: + +- one `Start investigation` message action +- allowlisted users only +- channel allowlist only +- reuse existing Discord/T3 intake behavior +- ephemeral confirmation back to Teams + +### Phase 2 + +Goal: optional operator note. + +Scope: + +- task module or dialog with one optional text field +- note is added to the T3 prompt and Discord seed + +### Phase 3 + +Goal: operational hardening. + +Scope: + +- audit logging for who triggered what +- clearer Teams-side confirmation including Discord thread link if feasible +- retry and idempotency handling for duplicate invoke deliveries + +## Implementation Plan + +1. Extract the current Teams escalation logic from `TeamsModule` into a reusable service. +2. Extend the intake reason model to include `message-action`. +3. Add a minimal Teams app manifest package in the repo for admin review. +4. Add a bot webhook route that validates Bot Framework requests. +5. Parse the message action invoke payload and map it to: + - Teams user id + - team id + - channel id + - target message id +6. Fetch the canonical message plus recent history and hosted contents through Graph. +7. Reuse the shared intake router to create or continue the Discord/T3 investigation. +8. Return a simple success/failure response to Teams. +9. Document deployment, app upload, admin approval, and per-channel enablement. + +## Open Questions + +- Do we want the message action to be installed tenant-wide but only enabled by config, or only installed into selected teams? +- Should message action be limited to root messages, or allowed on replies as well? +- Do we want the confirmation response to include a Discord link, or keep it minimal? +- Should message action use the same dedupe key as reactions and auto-detection, or intentionally allow a forced retrigger option later? + +## Microsoft References + +- Build message extensions: + https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/what-are-messaging-extensions +- Build bot-based message extensions: + https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/build-bot-based-message-extension +- Define action commands: + https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command +- Teams SDK action commands guide: + https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/in-depth-guides/message-extensions/action-commands From ad297e9e88b1f69c9c5874141bb7d13d063ca161 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:58:48 +0200 Subject: [PATCH 3/4] feat(teams): add native Discord-free T3 bridge --- .env.example | 5 + apps/discord-bot/package.json | 3 + apps/discord-bot/src/config.test.ts | 4 + apps/discord-bot/src/config.ts | 29 +- .../src/features/LinkedTurnRouter.ts | 174 +++++--- apps/discord-bot/src/features/TeamsModule.ts | 110 +++-- .../src/features/TeamsNativeApp.test.ts | 41 ++ .../src/features/TeamsNativeApp.ts | 379 ++++++++++++++++++ apps/discord-bot/src/main.ts | 18 +- apps/discord-bot/src/teams-main.ts | 46 +++ apps/discord-bot/src/teams/config.test.ts | 28 ++ apps/discord-bot/src/teams/config.ts | 16 +- apps/discord-bot/teams-app/README.md | 11 + apps/discord-bot/teams-app/manifest.json | 72 ++++ apps/discord-bot/teams.channels.example.json | 2 +- .../microsoft-teams-discord-bot.md | 374 +++++++++-------- .../microsoft-teams-message-action-plan.md | 195 +-------- pnpm-lock.yaml | 269 +++++++++++++ 18 files changed, 1329 insertions(+), 447 deletions(-) create mode 100644 apps/discord-bot/src/features/TeamsNativeApp.test.ts create mode 100644 apps/discord-bot/src/features/TeamsNativeApp.ts create mode 100644 apps/discord-bot/src/teams-main.ts create mode 100644 apps/discord-bot/teams-app/README.md create mode 100644 apps/discord-bot/teams-app/manifest.json diff --git a/.env.example b/.env.example index df0c81d0563..95416a17836 100644 --- a/.env.example +++ b/.env.example @@ -57,3 +57,8 @@ # TEAMS_CHANNELS_PATH=/absolute/path/to/apps/discord-bot/teams.channels.json # TEAMS_POLL_INTERVAL_SECONDS=60 # TEAMS_BOT_DISPLAY_NAME=T3 Code +# Native Teams SDK endpoint (Discord-free replacement mode) +# TEAMS_NATIVE_ENABLED=1 +# TEAMS_PORT=3978 +# TEAMS_MESSAGING_ENDPOINT=/api/messages +# TEAMS_DEFAULT_PROJECT_SHORT_NAME=my-project diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index 7750ef02784..cb5f3211b6a 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -6,12 +6,15 @@ "scripts": { "browser-profile": "node --import tsx src/browserProfileCli.ts", "dev": "node --watch --import tsx src/main.ts", + "dev:teams": "node --watch --import tsx src/teams-main.ts", "start": "node --import tsx src/main.ts", + "start:teams": "node --import tsx src/teams-main.ts", "typecheck": "tsgo --noEmit", "test": "vp test run" }, "dependencies": { "@effect/platform-node": "catalog:", + "@microsoft/teams.apps": "2.0.14", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/discord-bot/src/config.test.ts b/apps/discord-bot/src/config.test.ts index 4501c19388b..bbe7d0fb785 100644 --- a/apps/discord-bot/src/config.test.ts +++ b/apps/discord-bot/src/config.test.ts @@ -33,6 +33,10 @@ const baseConfig = { teamsChannelsPath: undefined, teamsPollIntervalSeconds: 60, teamsBotDisplayName: undefined, + teamsNativeEnabled: false, + teamsPort: 3978, + teamsMessagingEndpoint: "/api/messages" as const, + teamsDefaultProjectShortName: undefined, } satisfies DiscordBotConfig; describe("preferredModelSelection", () => { diff --git a/apps/discord-bot/src/config.ts b/apps/discord-bot/src/config.ts index e73915a2de3..198a6480681 100644 --- a/apps/discord-bot/src/config.ts +++ b/apps/discord-bot/src/config.ts @@ -12,7 +12,7 @@ import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; export interface DiscordBotConfig { - readonly discordToken: string; + readonly discordToken: string | undefined; readonly t3HttpBaseUrl: string; readonly t3BootstrapCredential: string | undefined; readonly t3BearerToken: string | undefined; @@ -67,6 +67,12 @@ export interface DiscordBotConfig { readonly teamsChannelsPath: string | undefined; readonly teamsPollIntervalSeconds: number; readonly teamsBotDisplayName: string | undefined; + /** Run the native Teams SDK activity/message-extension endpoint. */ + readonly teamsNativeEnabled: boolean; + readonly teamsPort: number; + readonly teamsMessagingEndpoint: `/${string}`; + /** Fallback alias for personal/group chats that do not map to a configured channel. */ + readonly teamsDefaultProjectShortName: string | undefined; } const RuntimeModeConfig = Schema.Literals([ @@ -82,7 +88,8 @@ const DEFAULT_BOT_MODEL = DEFAULT_MODEL; export const DiscordBotConfig: Effect.Effect = Effect.gen( function* () { const discordToken = yield* Config.redacted("DISCORD_BOT_TOKEN").pipe( - Config.map((value) => Redacted.value(value)), + Config.option, + Config.map((value) => (Option.isSome(value) ? Redacted.value(value.value) : undefined)), ); const t3HttpBaseUrl = yield* Config.string("T3_HTTP_BASE_URL").pipe( Config.withDefault("http://127.0.0.1:3773"), @@ -199,6 +206,20 @@ export const DiscordBotConfig: Effect.Effect { + const normalized = value.trim(); + return (normalized.startsWith("/") ? normalized : `/${normalized}`) as `/${string}`; + }), + ); + const teamsDefaultProjectShortName = yield* Config.string( + "TEAMS_DEFAULT_PROJECT_SHORT_NAME", + ).pipe(Config.option, Config.map(Option.getOrUndefined)); return { discordToken, @@ -230,6 +251,10 @@ export const DiscordBotConfig: Effect.Effect | undefined; + readonly promptContext?: LinkedTurnInput["promptContext"]; +} + /** * Shared start/continue path for Discord mentions and Teams intake. * Ported from aaaomega/t3code-pvt#1 and adapted to the current ThreadLinkStore + bridge APIs. @@ -83,6 +97,84 @@ export const resolveProjectFromShortName = Effect.fn("resolveProjectFromShortNam return { alias, project }; }); +/** + * Transport-neutral T3 start/continue path. Discord and Teams own their presentation and + * delivery lifecycle, while this function owns project resolution, model selection, T3 + * orchestration, and the durable source-conversation link. + */ +export const startOrContinueT3Turn = Effect.fn("startOrContinueT3Turn")(function* ( + config: DiscordBotConfig, + input: TransportNeutralLinkedTurnInput, +) { + const t3 = yield* T3Session; + const links = yield* ThreadLinkStore; + const attachments = input.attachments ?? []; + const hasExplicitModelFlags = + input.flags.provider !== undefined || input.flags.model !== undefined; + const resolved = yield* resolveProjectFromShortName(input.projectShortName); + const existing = yield* links.getBySourceThread( + input.source.sourceKind, + input.source.sourceThreadId, + ); + + if (existing !== null) { + const continueModelSelection = + input.stickyModelOnContinue === true && !hasExplicitModelFlags + ? undefined + : yield* t3.resolveModelSelection({ + project: resolved.project, + ...(input.flags.provider === undefined + ? {} + : { overrideInstanceId: input.flags.provider }), + ...(input.flags.model === undefined ? {} : { overrideModel: input.flags.model }), + }); + yield* t3.startTurn({ + threadId: existing.t3ThreadId, + prompt: input.prompt, + ...(continueModelSelection === undefined ? {} : { modelSelection: continueModelSelection }), + ...(input.flags.plan ? { interactionMode: "plan" as const } : {}), + ...(attachments.length > 0 ? { attachments } : {}), + }); + return { threadId: existing.t3ThreadId, isNew: false } as const; + } + + const modelSelection = yield* t3.resolveModelSelection({ + project: resolved.project, + ...(input.flags.provider === undefined ? {} : { overrideInstanceId: input.flags.provider }), + ...(input.flags.model === undefined ? {} : { overrideModel: input.flags.model }), + }); + const enrichedPrompt = resolveInitialPrompt({ + config, + projectShortName: input.projectShortName, + workspaceRoot: resolved.project.workspaceRoot, + prompt: input.prompt, + promptContext: input.promptContext, + guildId: input.externalTenantId, + discordThreadId: input.externalConversationId, + }); + const { threadId } = yield* t3.startTurnWithWorktree({ + project: resolved.project, + prompt: enrichedPrompt, + modelSelection, + interactionMode: input.flags.plan ? "plan" : "default", + baseBranch: input.flags.base ?? config.t3DefaultBaseBranch, + local: input.flags.local ?? false, + ...(attachments.length > 0 ? { attachments } : {}), + }); + + yield* links.put({ + sourceKind: input.source.sourceKind, + sourceThreadId: input.source.sourceThreadId, + discordThreadId: input.externalConversationId, + t3ThreadId: threadId, + projectId: resolved.project.id, + channelId: input.externalParentId, + guildId: input.externalTenantId, + createdAt: DateTime.formatIso(DateTime.nowUnsafe()), + }); + return { threadId, isNew: true } as const; +}); + function resolveInitialPrompt(input: { readonly config: DiscordBotConfig; readonly projectShortName: string; @@ -125,14 +217,7 @@ export const startOrContinueLinkedTurn = Effect.fn("startOrContinueLinkedTurn")( input: LinkedTurnInput, ) { const rest = yield* DiscordREST; - const t3 = yield* T3Session; const links = yield* ThreadLinkStore; - const attachments = input.attachments ?? []; - const hasExplicitModelFlags = - input.flags.provider !== undefined || input.flags.model !== undefined; - - const resolved = yield* resolveProjectFromShortName(input.projectShortName); - const existing = yield* links.getBySourceThread( input.source.sourceKind, input.source.sourceThreadId, @@ -160,73 +245,36 @@ export const startOrContinueLinkedTurn = Effect.fn("startOrContinueLinkedTurn")( t3ThreadId: existing.t3ThreadId, workingAckMessageId, }); - const continueModelSelection = - input.stickyModelOnContinue === true && !hasExplicitModelFlags - ? undefined - : yield* t3.resolveModelSelection({ - project: resolved.project, - ...(input.flags.provider === undefined - ? {} - : { overrideInstanceId: input.flags.provider }), - ...(input.flags.model === undefined ? {} : { overrideModel: input.flags.model }), - }); - yield* t3.startTurn({ - threadId: existing.t3ThreadId, - prompt: input.prompt, - ...(continueModelSelection === undefined ? {} : { modelSelection: continueModelSelection }), - ...(input.flags.plan ? { interactionMode: "plan" as const } : {}), - ...(attachments.length > 0 ? { attachments } : {}), - }); - return existing.t3ThreadId; } - const modelSelection = yield* t3.resolveModelSelection({ - project: resolved.project, - ...(input.flags.provider === undefined ? {} : { overrideInstanceId: input.flags.provider }), - ...(input.flags.model === undefined ? {} : { overrideModel: input.flags.model }), - }); - - const enrichedPrompt = resolveInitialPrompt({ - config, + const turn = yield* startOrContinueT3Turn(config, { + source: input.source, + externalConversationId: input.discordThreadId, + externalParentId: input.discordParentChannelId, + externalTenantId: input.discordGuildId, projectShortName: input.projectShortName, - workspaceRoot: resolved.project.workspaceRoot, prompt: input.prompt, - promptContext: input.promptContext, - guildId: input.discordGuildId, - discordThreadId: input.discordThreadId, - }); - - const { threadId } = yield* t3.startTurnWithWorktree({ - project: resolved.project, - prompt: enrichedPrompt, - modelSelection, - interactionMode: input.flags.plan ? "plan" : "default", - baseBranch: input.flags.base ?? config.t3DefaultBaseBranch, - local: input.flags.local ?? false, - ...(attachments.length > 0 ? { attachments } : {}), - }); - - yield* links.put({ - sourceKind: input.source.sourceKind, - sourceThreadId: input.source.sourceThreadId, - discordThreadId: input.discordThreadId, - t3ThreadId: threadId, - projectId: resolved.project.id, - channelId: input.discordParentChannelId, - guildId: input.discordGuildId, - createdAt: DateTime.formatIso(DateTime.nowUnsafe()), + flags: input.flags, + ...(input.stickyModelOnContinue === undefined + ? {} + : { stickyModelOnContinue: input.stickyModelOnContinue }), + ...(input.attachments === undefined ? {} : { attachments: input.attachments }), + ...(input.promptContext === undefined ? {} : { promptContext: input.promptContext }), }); + const threadId = turn.threadId; const webLink = config.webUiBaseUrl === undefined ? null : `${config.webUiBaseUrl.replace(/\/$/u, "")}/?thread=${threadId}`; - yield* rest.createMessage(input.discordThreadId, { - content: [...input.announceLines, webLink === null ? null : `Open in Omegent: ${webLink}`] - .filter((line): line is string => line !== null && line.trim().length > 0) - .join("\n"), - }); + if (turn.isNew) { + yield* rest.createMessage(input.discordThreadId, { + content: [...input.announceLines, webLink === null ? null : `Open in Omegent: ${webLink}`] + .filter((line): line is string => line !== null && line.trim().length > 0) + .join("\n"), + }); + } yield* bridgeThreadToDiscord({ discordChannelId: input.discordThreadId, diff --git a/apps/discord-bot/src/features/TeamsModule.ts b/apps/discord-bot/src/features/TeamsModule.ts index a668e3da6f8..5a0218a6c39 100644 --- a/apps/discord-bot/src/features/TeamsModule.ts +++ b/apps/discord-bot/src/features/TeamsModule.ts @@ -9,7 +9,7 @@ import { createMessageWithAttachments, DiscordUploadError } from "../presentatio import { TeamsSeenStore } from "../store/TeamsSeenStore.ts"; import { ThreadLinkStore } from "../store/ThreadLinkStore.ts"; import { truncateTitle } from "../presentation/messages.ts"; -import { startOrContinueLinkedTurn } from "./LinkedTurnRouter.ts"; +import { startOrContinueLinkedTurn, startOrContinueT3Turn } from "./LinkedTurnRouter.ts"; import type { DiscordUploadFile } from "../presentation/discordFiles.ts"; import { downloadTeamsMessageImages } from "../teams/attachments.ts"; import { loadTeamsChannelConfigsFromFileSync, type TeamsChannelConfig } from "../teams/config.ts"; @@ -233,6 +233,14 @@ export const runTeamsModule = Effect.fn("runTeamsModule")(function* (config: Dis reason: "mention" | "german-problem" | "allowlisted-reaction" | "internal-tag", imageFiles: ReadonlyArray, ) { + const discordChannelId = channel.discordChannelId; + if (discordChannelId === undefined) { + return yield* Effect.fail( + new Error( + `Teams channel ${channel.teamId}/${channel.channelId} uses Discord delivery without discordChannelId.`, + ), + ); + } const seedContent = buildTeamsSeedMessage({ company: channel.company, environment: channel.environment, @@ -242,7 +250,7 @@ export const runTeamsModule = Effect.fn("runTeamsModule")(function* (config: Dis }); const seed = imageFiles.length === 0 - ? yield* rest.createMessage(channel.discordChannelId, { + ? yield* rest.createMessage(discordChannelId, { content: seedContent, }) : yield* Effect.tryPromise({ @@ -250,7 +258,7 @@ export const runTeamsModule = Effect.fn("runTeamsModule")(function* (config: Dis createMessageWithAttachments({ baseUrl: discordConfig.rest.baseUrl, botToken: Redacted.value(discordConfig.token), - channelId: channel.discordChannelId, + channelId: discordChannelId, content: seedContent, files: imageFiles, }), @@ -259,7 +267,7 @@ export const runTeamsModule = Effect.fn("runTeamsModule")(function* (config: Dis ? cause : new DiscordUploadError(cause instanceof Error ? cause.message : String(cause)), }); - const discordThread = yield* rest.createThreadFromMessage(channel.discordChannelId, seed.id, { + const discordThread = yield* rest.createThreadFromMessage(discordChannelId, seed.id, { name: truncateTitle( buildTeamsIncidentTitle({ company: channel.company, @@ -488,15 +496,6 @@ export const runTeamsModule = Effect.fn("runTeamsModule")(function* (config: Dis }); } - const existing = yield* links.getBySourceThread("teams", rootKey); - const discordThreadId = - existing?.discordThreadId ?? - (yield* openDiscordThread( - channel, - trigger.targetMessage, - trigger.reason, - imageDownloads.discordFiles, - )); const history = recentHistoryForMessage( channel, messages, @@ -514,32 +513,69 @@ export const runTeamsModule = Effect.fn("runTeamsModule")(function* (config: Dis triggerMessage: trigger.triggerMessage, history, }); - yield* startOrContinueLinkedTurn(config, { - source: { - sourceKind: "teams", - sourceThreadId: rootKey, - }, - discordThreadId, - discordParentChannelId: channel.discordChannelId, - discordGuildId: "", - projectShortName: channel.projectShortName, - prompt, - flags: {}, - ...(imageDownloads.t3Uploads.length > 0 ? { attachments: imageDownloads.t3Uploads } : {}), - announceLines: [ - `Linked **${channel.projectShortName}**`, - `Source: Teams / ${channel.channelName}`, - `Company: **${channel.company}**`, - `Environment: **${channel.environment}**`, - ], - promptContext: { - kind: "raw", - value: prompt, - }, - }); + if (channel.deliveryMode === "discord") { + const discordChannelId = channel.discordChannelId; + if (discordChannelId === undefined) { + return yield* Effect.fail( + new Error( + `Teams channel ${channel.teamId}/${channel.channelId} uses Discord delivery without discordChannelId.`, + ), + ); + } + const existing = yield* links.getBySourceThread("teams", rootKey); + const discordThreadId = + existing?.discordThreadId ?? + (yield* openDiscordThread( + channel, + trigger.targetMessage, + trigger.reason, + imageDownloads.discordFiles, + )); + yield* startOrContinueLinkedTurn(config, { + source: { + sourceKind: "teams", + sourceThreadId: rootKey, + }, + discordThreadId, + discordParentChannelId: discordChannelId, + discordGuildId: "", + projectShortName: channel.projectShortName, + prompt, + flags: {}, + ...(imageDownloads.t3Uploads.length > 0 ? { attachments: imageDownloads.t3Uploads } : {}), + announceLines: [ + `Linked **${channel.projectShortName}**`, + `Source: Teams / ${channel.channelName}`, + `Company: **${channel.company}**`, + `Environment: **${channel.environment}**`, + ], + promptContext: { + kind: "raw", + value: prompt, + }, + }); + } else { + yield* startOrContinueT3Turn(config, { + source: { + sourceKind: "teams", + sourceThreadId: rootKey, + }, + externalConversationId: rootKey, + externalParentId: `${channel.teamId}/${channel.channelId}`, + externalTenantId: config.teamsTenantId ?? "", + projectShortName: channel.projectShortName, + prompt, + flags: {}, + ...(imageDownloads.t3Uploads.length > 0 ? { attachments: imageDownloads.t3Uploads } : {}), + promptContext: { + kind: "raw", + value: prompt, + }, + }); + } processedRootKeys.add(rootKey); - if (trigger.reason === "mention") { + if (trigger.reason === "mention" || channel.deliveryMode !== "discord") { yield* postTeamsWebhookAck(channel, trigger.targetMessage); } }); diff --git a/apps/discord-bot/src/features/TeamsNativeApp.test.ts b/apps/discord-bot/src/features/TeamsNativeApp.test.ts new file mode 100644 index 00000000000..8e9bea12f14 --- /dev/null +++ b/apps/discord-bot/src/features/TeamsNativeApp.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { sourceConversationKey, splitTeamsMessage } from "./TeamsNativeApp.ts"; + +describe("sourceConversationKey", () => { + it("keeps native Teams conversations distinct by tenant and location", () => { + expect( + sourceConversationKey({ + tenantId: "tenant", + teamId: "team", + channelId: "channel", + conversationId: "conversation;messageid=root", + }), + ).toBe("native/tenant/team/channel/conversation;messageid=root"); + }); + + it("supports personal chats", () => { + expect( + sourceConversationKey({ + tenantId: "tenant", + teamId: undefined, + channelId: undefined, + conversationId: "personal-chat", + }), + ).toBe("native/tenant/chat/chat/personal-chat"); + }); +}); + +describe("splitTeamsMessage", () => { + it("keeps short answers intact", () => { + expect(splitTeamsMessage("done")).toEqual(["done"]); + }); + + it("splits large answers below the Teams delivery ceiling without losing text", () => { + const input = `${"a".repeat(15_000)}\n\n${"b".repeat(15_000)}`; + const chunks = splitTeamsMessage(input); + expect(chunks).toHaveLength(2); + expect(chunks.every((chunk) => chunk.length <= 25_000)).toBe(true); + expect(chunks.join("\n\n")).toBe(input); + }); +}); diff --git a/apps/discord-bot/src/features/TeamsNativeApp.ts b/apps/discord-bot/src/features/TeamsNativeApp.ts new file mode 100644 index 00000000000..6fdc7b5352d --- /dev/null +++ b/apps/discord-bot/src/features/TeamsNativeApp.ts @@ -0,0 +1,379 @@ +// @effect-diagnostics anyUnknownInErrorContext:off globalErrorInEffectCatch:off globalErrorInEffectFailure:off globalPromise:off missingEffectContext:off tryCatchInEffectGen:off preferSchemaOverJson:off +import { App } from "@microsoft/teams.apps"; +import type { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import type { DiscordBotConfig } from "../config.ts"; +import { ProjectAliasStore } from "../projectAliases.ts"; +import { ThreadLinkStore } from "../store/ThreadLinkStore.ts"; +import { T3Session } from "../t3/T3Session.ts"; +import { loadTeamsChannelConfigsFromFileSync, type TeamsChannelConfig } from "../teams/config.ts"; +import { teamsMessageText } from "../teams/presentation.ts"; +import { startOrContinueT3Turn } from "./LinkedTurnRouter.ts"; +import { finalAnswerText } from "./ResponseBridge.ts"; + +interface TeamsConversationCoordinates { + readonly tenantId: string; + readonly teamId: string | undefined; + readonly channelId: string | undefined; + readonly conversationId: string; +} + +function coordinates(activity: { + readonly conversation: { readonly id: string; readonly tenantId?: string | undefined }; + readonly channelData?: { + readonly tenant?: { readonly id?: string | undefined } | undefined; + readonly team?: { readonly id?: string | undefined } | undefined; + readonly channel?: { readonly id?: string | undefined } | undefined; + }; +}): TeamsConversationCoordinates { + return { + tenantId: activity.channelData?.tenant?.id ?? activity.conversation.tenantId ?? "", + teamId: activity.channelData?.team?.id, + channelId: activity.channelData?.channel?.id, + conversationId: activity.conversation.id, + }; +} + +function channelForCoordinates( + channels: ReadonlyArray, + input: TeamsConversationCoordinates, +): TeamsChannelConfig | undefined { + return channels.find( + (channel) => channel.teamId === input.teamId && channel.channelId === input.channelId, + ); +} + +export function sourceConversationKey(input: TeamsConversationCoordinates): string { + return `native/${input.tenantId}/${input.teamId ?? "chat"}/${input.channelId ?? "chat"}/${input.conversationId}`; +} + +function settled(status: string | null | undefined): boolean { + return status !== "starting" && status !== "running"; +} + +export function splitTeamsMessage(text: string): ReadonlyArray { + const normalized = text.trim(); + if (normalized.length <= 25_000) return [normalized]; + const chunks: string[] = []; + let remaining = normalized; + while (remaining.length > 25_000) { + const candidate = remaining.slice(0, 25_000); + const splitAt = Math.max(candidate.lastIndexOf("\n\n"), candidate.lastIndexOf("\n")); + const end = splitAt >= 10_000 ? splitAt : 25_000; + chunks.push(remaining.slice(0, end).trimEnd()); + remaining = remaining.slice(end).trimStart(); + } + if (remaining.length > 0) chunks.push(remaining); + return chunks; +} + +const waitForFinalAnswer = Effect.fn("waitForFinalAnswer")(function* (input: { + readonly t3ThreadId: ThreadId; + readonly baseline: string; + readonly baselineTurnId: string | null; + readonly send: (text: string) => Promise; +}) { + const t3 = yield* T3Session; + while (true) { + yield* Effect.sleep("1 second"); + const snapshot = yield* t3.fetchThreadDetail(input.t3ThreadId).pipe( + Effect.catch((error) => + Effect.logWarning("Teams final-answer poll failed", { + threadId: input.t3ThreadId, + error: String(error), + }).pipe(Effect.as(null)), + ), + ); + if (snapshot === null) continue; + const answer = finalAnswerText(snapshot.thread); + const latestTurnId = snapshot.thread.latestTurn?.turnId ?? null; + if ( + answer.length === 0 || + (answer === input.baseline && latestTurnId === input.baselineTurnId) || + !settled(snapshot.thread.session?.status) + ) { + continue; + } + for (const chunk of splitTeamsMessage(answer)) { + yield* Effect.tryPromise({ + try: () => input.send(chunk), + catch: (cause) => new Error(String(cause)), + }); + } + return; + } +}); + +function messageActionCard(defaultProject: string | undefined) { + return { + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "TextBlock", + text: "Start a T3 investigation from this Teams message", + weight: "Bolder", + wrap: true, + }, + { + type: "Input.Text", + id: "projectShortName", + label: "Project alias", + value: defaultProject ?? "", + isRequired: true, + errorMessage: "Enter a project alias configured in T3_PROJECT_ALIASES_PATH.", + }, + { + type: "Input.Text", + id: "instructions", + label: "Additional instructions", + isMultiline: true, + placeholder: "Optional context or desired outcome", + }, + { + type: "ActionSet", + actions: [ + { + type: "Action.Submit", + title: "Start investigation", + data: { action: "startT3Investigation" }, + }, + ], + }, + ], + $schema: "https://adaptivecards.io/schemas/adaptive-card.json", + }, + } as const; +} + +/** + * Native Teams SDK endpoint. This is independent from Discord gateway/services and can be + * deployed with only Teams + T3 credentials. + */ +export const runTeamsNativeApp = Effect.fn("runTeamsNativeApp")(function* ( + config: DiscordBotConfig, +) { + if (!config.teamsNativeEnabled) { + yield* Effect.logInfo("Native Teams app disabled"); + return; + } + if ( + config.teamsClientId === undefined || + config.teamsClientSecret === undefined || + config.teamsTenantId === undefined + ) { + return yield* Effect.fail( + new Error( + "TEAMS_NATIVE_ENABLED requires TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID.", + ), + ); + } + + const channels = + config.teamsChannelsPath === undefined + ? [] + : loadTeamsChannelConfigsFromFileSync(config.teamsChannelsPath); + const t3 = yield* T3Session; + const links = yield* ThreadLinkStore; + const services = yield* Effect.context(); + const run = Effect.runPromiseWith(services); + + const app = new App({ + clientId: config.teamsClientId, + clientSecret: config.teamsClientSecret, + tenantId: config.teamsTenantId, + messagingEndpoint: config.teamsMessagingEndpoint, + activity: { mentions: { stripText: true } }, + }); + + app.on("message", async ({ activity, send, reply }) => { + await run( + Effect.gen(function* () { + const location = coordinates(activity); + const sourceKey = sourceConversationKey(location); + const channel = channelForCoordinates(channels, location); + const projectShortName = channel?.projectShortName ?? config.teamsDefaultProjectShortName; + const prompt = (activity.text ?? "").trim(); + const existing = yield* links.getBySourceThread("teams", sourceKey); + + const stop = /^(?:\/?stop|cancel)$/iu.test(prompt); + if (stop) { + if (existing === null) { + yield* Effect.promise(() => reply("There is no linked T3 thread to stop.")); + return; + } + yield* t3.interrupt(existing.t3ThreadId); + yield* Effect.promise(() => reply("Stopped the active T3 turn.")); + return; + } + + const approval = /^(?:\/?)(approve|deny)\s+(\S+)$/iu.exec(prompt); + if (approval !== null) { + if (existing === null) { + yield* Effect.promise(() => reply("There is no linked T3 thread for that approval.")); + return; + } + yield* t3.respondToApproval( + existing.t3ThreadId, + approval[2]!, + approval[1]!.toLowerCase() === "approve" ? "accept" : "decline", + ); + yield* Effect.promise(() => reply(`Approval ${approval[1]!.toLowerCase()}ed.`)); + return; + } + + if (prompt.length === 0) return; + if (projectShortName === undefined) { + yield* Effect.promise(() => + reply( + "No T3 project is mapped to this Teams location. Configure TEAMS_CHANNELS_PATH or TEAMS_DEFAULT_PROJECT_SHORT_NAME.", + ), + ); + return; + } + + const baselineSnapshot = + existing === null + ? null + : yield* t3 + .fetchThreadDetail(existing.t3ThreadId) + .pipe(Effect.orElseSucceed(() => null)); + const baseline = baselineSnapshot === null ? "" : finalAnswerText(baselineSnapshot.thread); + const baselineTurnId = baselineSnapshot?.thread.latestTurn?.turnId ?? null; + const turn = yield* startOrContinueT3Turn(config, { + source: { sourceKind: "teams", sourceThreadId: sourceKey }, + externalConversationId: location.conversationId, + externalParentId: `${location.teamId ?? "chat"}/${location.channelId ?? "chat"}`, + externalTenantId: location.tenantId, + projectShortName, + prompt, + flags: {}, + stickyModelOnContinue: true, + promptContext: { kind: "raw", value: prompt }, + }); + + const webLink = + config.webUiBaseUrl === undefined + ? null + : `${config.webUiBaseUrl.replace(/\/$/u, "")}/?thread=${turn.threadId}`; + yield* Effect.promise(() => + reply( + [ + turn.isNew + ? `Started T3 for **${projectShortName}**.` + : "Continued the linked T3 thread.", + webLink === null ? null : `[Open in T3 Code](${webLink})`, + ] + .filter((line): line is string => line !== null) + .join("\n"), + ), + ); + yield* Effect.forkDetach( + waitForFinalAnswer({ + t3ThreadId: turn.threadId, + baseline, + baselineTurnId, + send: (text) => send(text), + }).pipe( + Effect.catchCause((cause) => + Effect.logError("Native Teams answer delivery failed", { + threadId: turn.threadId, + cause, + }), + ), + ), + ); + }).pipe( + Effect.catch((error) => + Effect.logError("Native Teams message failed", { error: String(error) }).pipe( + Effect.andThen( + Effect.promise(() => + reply( + "T3 could not process this message. Check the bridge logs and configuration.", + ), + ), + ), + ), + ), + ), + ); + }); + + app.on("message.ext.open", async ({ activity }) => { + const location = coordinates(activity); + const channel = channelForCoordinates(channels, location); + return { + task: { + type: "continue", + value: { + title: "Start T3 investigation", + height: "medium", + width: "medium", + card: messageActionCard(channel?.projectShortName ?? config.teamsDefaultProjectShortName), + }, + }, + }; + }); + + app.on("message.ext.submit", async ({ activity }) => { + const data = activity.value.data as + | { readonly projectShortName?: unknown; readonly instructions?: unknown } + | undefined; + const projectShortName = + typeof data?.projectShortName === "string" ? data.projectShortName.trim() : ""; + if (projectShortName.length === 0) { + return { task: { type: "message", value: "A project alias is required." } }; + } + const sourceMessage = teamsMessageText({ + id: activity.value.messagePayload?.id ?? activity.id, + body: activity.value.messagePayload?.body, + from: activity.value.messagePayload?.from, + }); + const instructions = typeof data?.instructions === "string" ? data.instructions.trim() : ""; + const prompt = [ + "Investigate the following Microsoft Teams message.", + sourceMessage, + instructions.length === 0 ? null : `Additional instructions: ${instructions}`, + ] + .filter((line): line is string => line !== null && line.length > 0) + .join("\n\n"); + const location = coordinates(activity); + const messageId = activity.value.messagePayload?.id ?? activity.id; + const sourceKey = `message-action/${location.tenantId}/${messageId}`; + + Effect.runForkWith(services)( + startOrContinueT3Turn(config, { + source: { sourceKind: "teams", sourceThreadId: sourceKey }, + externalConversationId: sourceKey, + externalParentId: `${location.teamId ?? "chat"}/${location.channelId ?? "chat"}`, + externalTenantId: location.tenantId, + projectShortName, + prompt, + flags: {}, + promptContext: { kind: "raw", value: prompt }, + }).pipe( + Effect.catchCause((cause) => + Effect.logError("Teams message action failed", { sourceKey, cause }), + ), + ), + ); + return { + task: { + type: "message", + value: "T3 investigation started. Open T3 Code to follow progress.", + }, + }; + }); + + yield* Effect.tryPromise({ + try: () => app.start(config.teamsPort), + catch: (cause) => new Error(`Could not start native Teams app: ${String(cause)}`), + }); + yield* Effect.logInfo("Native Teams app listening", { + port: config.teamsPort, + endpoint: config.teamsMessagingEndpoint, + }); +}); diff --git a/apps/discord-bot/src/main.ts b/apps/discord-bot/src/main.ts index f91953a67de..027d8f71fcd 100644 --- a/apps/discord-bot/src/main.ts +++ b/apps/discord-bot/src/main.ts @@ -16,6 +16,7 @@ import { layer as bridgeHubLayer } from "./features/BridgeHub.ts"; import { DiscordBotRunning, MentionRouterLive } from "./features/MentionRouter.ts"; import { runBridge } from "./features/ResponseBridge.ts"; import { runTeamsModule } from "./features/TeamsModule.ts"; +import { runTeamsNativeApp } from "./features/TeamsNativeApp.ts"; import { backfillThreadInfoPins } from "./features/ThreadInfoPin.ts"; import { rehydrateBridges } from "./features/ThreadRestore.ts"; import { layerFromOptionalPath as identityMapStoreLayer, IdentityMapStore } from "./identityMap.ts"; @@ -55,7 +56,15 @@ const BotObservabilityLive = Layer.unwrap( const MainLayer = Layer.unwrap( Effect.gen(function* () { const botConfig = yield* DiscordBotConfig; - const discord = makeDiscordLayer(botConfig.discordToken); + const discordToken = botConfig.discordToken; + if (discordToken === undefined) { + return yield* Effect.die( + new Error( + "DISCORD_BOT_TOKEN is required by the Discord entrypoint. Use start:teams for Teams-only deployments.", + ), + ); + } + const discord = makeDiscordLayer(discordToken); const bridgeHub = bridgeHubLayer(runBridge); const core = Layer.mergeAll( t3SessionLayer(botConfig), @@ -167,6 +176,13 @@ const program = Effect.gen(function* () { ), ); + // Optional native Teams SDK endpoint. This can run beside Discord during migration. + yield* runTeamsNativeApp(botConfig).pipe( + Effect.catchCause((cause) => + Effect.logError("Native Teams app stopped").pipe(Effect.andThen(Effect.logError(cause))), + ), + ); + // Keep process alive; router fibers are scoped to this layer lifetime. return yield* Effect.never; }); diff --git a/apps/discord-bot/src/teams-main.ts b/apps/discord-bot/src/teams-main.ts new file mode 100644 index 00000000000..853cbba87fe --- /dev/null +++ b/apps/discord-bot/src/teams-main.ts @@ -0,0 +1,46 @@ +// @effect-diagnostics layerMergeAllWithDependencies:off missingEffectContext:off anyUnknownInErrorContext:off unsafeEffectTypeAssertion:off multipleEffectProvide:off +import { NodeRuntime } from "@effect/platform-node"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; + +import { DiscordBotConfig } from "./config.ts"; +import { runTeamsNativeApp } from "./features/TeamsNativeApp.ts"; +import { layerFromOptionalPath as projectAliasStoreLayer } from "./projectAliases.ts"; +import { layer as threadLinkStoreLayer } from "./store/ThreadLinkStore.ts"; +import { T3Session, layer as t3SessionLayer } from "./t3/T3Session.ts"; + +const TeamsMainLayer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* DiscordBotConfig; + return Layer.mergeAll( + t3SessionLayer(config), + threadLinkStoreLayer(config.dataDir), + projectAliasStoreLayer(config.projectAliasesPath), + ).pipe( + Layer.provideMerge(ConfigProvider.layer(ConfigProvider.fromEnv())), + Layer.provideMerge(Logger.layer([Logger.consolePretty()])), + ); + }), +).pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv()))); + +const program = Effect.gen(function* () { + const config = yield* DiscordBotConfig; + if (!config.teamsNativeEnabled) { + return yield* Effect.die( + new Error("TEAMS_NATIVE_ENABLED=1 is required by the Teams-only entrypoint."), + ); + } + const t3 = yield* T3Session; + yield* t3.connectUntilReady(); + yield* runTeamsNativeApp(config); + return yield* Effect.never; +}); + +NodeRuntime.runMain( + program.pipe( + Effect.provide(TeamsMainLayer), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv())), + ) as Effect.Effect, +); diff --git a/apps/discord-bot/src/teams/config.test.ts b/apps/discord-bot/src/teams/config.test.ts index d36fb02aae2..50ebca1ed6a 100644 --- a/apps/discord-bot/src/teams/config.test.ts +++ b/apps/discord-bot/src/teams/config.test.ts @@ -48,5 +48,33 @@ describe("loadTeamsChannelConfigsFromFileSync", () => { expect(configs[0]?.internalUserIds).toEqual(["user-1", "user-2"]); expect(configs[0]?.reactionTriggerTypes).toEqual(["eyes", "🚨"]); expect(configs[0]?.messageTagTriggers).toEqual(["#investigate", "#triage"]); + expect(configs[0]?.deliveryMode).toBe("discord"); + }); + + it("supports Discord-free native channel mappings", async () => { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "teams-config-")); + tempDirs.push(dir); + const filePath = NodePath.join(dir, "teams.json"); + await NodeFSP.writeFile( + filePath, + JSON.stringify([ + { + teamId: "team-1", + channelId: "channel-1", + channelName: "Prod", + projectShortName: "scanner", + deliveryMode: "native", + company: "Acme", + environment: "prod", + companyKeywords: ["acme"], + environmentKeywords: ["prod"], + }, + ]), + "utf8", + ); + + const configs = loadTeamsChannelConfigsFromFileSync(filePath); + expect(configs[0]?.deliveryMode).toBe("native"); + expect(configs[0]?.discordChannelId).toBeUndefined(); }); }); diff --git a/apps/discord-bot/src/teams/config.ts b/apps/discord-bot/src/teams/config.ts index f96a2734dc1..7ceaf9a7b4d 100644 --- a/apps/discord-bot/src/teams/config.ts +++ b/apps/discord-bot/src/teams/config.ts @@ -9,7 +9,13 @@ export interface TeamsChannelConfig { readonly channelId: string; readonly channelName: string; readonly projectShortName: string; - readonly discordChannelId: string; + readonly discordChannelId?: string | undefined; + /** + * discord: mirror into a Discord thread (legacy compatibility). + * t3-only: start T3 directly and acknowledge through the optional Teams workflow webhook. + * native: native Teams activities own replies; Graph polling only supplies ambient triggers. + */ + readonly deliveryMode?: "discord" | "t3-only" | "native" | undefined; readonly company: string; readonly environment: string; readonly companyKeywords: ReadonlyArray; @@ -35,7 +41,11 @@ function isChannelConfig(value: unknown): value is TeamsChannelConfig { typeof candidate.channelId === "string" && typeof candidate.channelName === "string" && typeof candidate.projectShortName === "string" && - typeof candidate.discordChannelId === "string" && + (candidate.discordChannelId === undefined || typeof candidate.discordChannelId === "string") && + (candidate.deliveryMode === undefined || + candidate.deliveryMode === "discord" || + candidate.deliveryMode === "t3-only" || + candidate.deliveryMode === "native") && typeof candidate.company === "string" && typeof candidate.environment === "string" && isStringArray(candidate.companyKeywords) && @@ -76,6 +86,8 @@ export function loadTeamsChannelConfigsFromFileSync( return channels.map((channel) => ({ ...channel, + deliveryMode: + channel.deliveryMode ?? (channel.discordChannelId === undefined ? "t3-only" : "discord"), projectShortName: channel.projectShortName.trim().toLowerCase(), companyKeywords: channel.companyKeywords.map((value: string) => value.trim()).filter(Boolean), environmentKeywords: channel.environmentKeywords diff --git a/apps/discord-bot/teams-app/README.md b/apps/discord-bot/teams-app/README.md new file mode 100644 index 00000000000..b3db5c7da3d --- /dev/null +++ b/apps/discord-bot/teams-app/README.md @@ -0,0 +1,11 @@ +# Teams app package + +Before zipping this directory: + +1. Replace `${TEAMS_APP_ID}` and `${TEAMS_CLIENT_ID}` with the app/client ID. +2. Replace `${PUBLIC_BASE_URL}` and `${PUBLIC_HOST}` with the public HTTPS origin and host. +3. Add a transparent 32×32 `outline.png` and a full-color 192×192 `color.png`. +4. Zip `manifest.json`, `outline.png`, and `color.png` at the archive root. + +The app sends activities to the Azure Bot messaging endpoint configured separately as +`https:///api/messages`. diff --git a/apps/discord-bot/teams-app/manifest.json b/apps/discord-bot/teams-app/manifest.json new file mode 100644 index 00000000000..5b3eb7bbf64 --- /dev/null +++ b/apps/discord-bot/teams-app/manifest.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.23/MicrosoftTeams.schema.json", + "manifestVersion": "1.23", + "version": "1.0.0", + "id": "${TEAMS_APP_ID}", + "developer": { + "name": "T3 Code", + "websiteUrl": "${PUBLIC_BASE_URL}", + "privacyUrl": "${PUBLIC_BASE_URL}/privacy", + "termsOfUseUrl": "${PUBLIC_BASE_URL}/terms" + }, + "name": { + "short": "T3 Code", + "full": "T3 Code engineering agent" + }, + "description": { + "short": "Start and continue T3 Code work from Microsoft Teams.", + "full": "Runs T3 Code engineering sessions from Teams conversations or from the Start T3 investigation message action." + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#5B5FC7", + "bots": [ + { + "botId": "${TEAMS_CLIENT_ID}", + "scopes": ["personal", "team", "groupChat"], + "supportsFiles": true, + "isNotificationOnly": false, + "supportsCalling": false, + "supportsVideo": false, + "commandLists": [ + { + "scopes": ["personal", "team", "groupChat"], + "commands": [ + { + "title": "stop", + "description": "Stop the active T3 turn in this conversation." + }, + { + "title": "approve", + "description": "Approve a pending T3 request by request id." + }, + { + "title": "deny", + "description": "Deny a pending T3 request by request id." + } + ] + } + ] + } + ], + "composeExtensions": [ + { + "botId": "${TEAMS_CLIENT_ID}", + "commands": [ + { + "id": "startT3Investigation", + "type": "action", + "title": "Start T3 investigation", + "description": "Start a T3 investigation from the selected message.", + "initialRun": false, + "fetchTask": true, + "context": ["message"] + } + ] + } + ], + "permissions": ["identity", "messageTeamMembers"], + "validDomains": ["${PUBLIC_HOST}"] +} diff --git a/apps/discord-bot/teams.channels.example.json b/apps/discord-bot/teams.channels.example.json index f2fe89cb32e..4d7d8f163a0 100644 --- a/apps/discord-bot/teams.channels.example.json +++ b/apps/discord-bot/teams.channels.example.json @@ -5,7 +5,7 @@ "channelId": "19:examplechannel@thread.tacv2", "channelName": "Acme Prod Incidents", "projectShortName": "macs-scanner", - "discordChannelId": "123456789012345678", + "deliveryMode": "native", "company": "Acme", "environment": "prod", "companyKeywords": ["acme", "kunde acme"], diff --git a/docs/integrations/microsoft-teams-discord-bot.md b/docs/integrations/microsoft-teams-discord-bot.md index faadaa9743a..afb62fb4cf9 100644 --- a/docs/integrations/microsoft-teams-discord-bot.md +++ b/docs/integrations/microsoft-teams-discord-bot.md @@ -1,162 +1,216 @@ -# Microsoft Teams Intake Module +# Deploying the T3 Code Microsoft Teams integration + +T3 Code supports Microsoft Teams in two independent operating modes: + +1. **Teams replacement mode** runs a native Teams app and does not require Discord credentials or + a Discord gateway connection. Teams messages start or continue T3 threads; final answers return + to Teams. +2. **Intake-only mode** starts T3 from a selected Teams message without adding the bot to that + conversation. The bot-based message action invokes the service, returns a private confirmation, + and leaves the T3 thread available in T3 Code. + +The legacy Graph poller is still available for automatic assessment, allowlisted reactions, and +tag triggers. Its per-channel `deliveryMode` can be `discord`, `t3-only`, or `native`. + +## Runtime capabilities + +| Capability | Teams replacement | Message action | Graph poller | +| ------------------------------------ | -------------------------------------------- | ----------------------- | ----------------------------------------- | +| Start/continue a T3 thread | Yes | Yes | Yes | +| Discord required | No | No | Only for `deliveryMode: "discord"` | +| Bot installed in target conversation | Yes | No | No | +| Final answer posted to Teams | Yes | No; follow in T3 Code | Optional workflow webhook acknowledgement | +| Stop a turn | `stop` | In T3 Code | In T3 Code | +| Approve/deny | `approve ` / `deny ` | In T3 Code | In T3 Code | +| Automatic/reaction/tag triggers | Optional Graph poller | Explicit message action | Yes | + +Microsoft requires a Teams app to be installed in a team or group chat before its bot can send +messages there. A message extension can be invoked from a message without making the bot a +participant in that conversation; that path intentionally does not attempt a later bot reply. + +## 1. Prepare T3 Code + +1. Run the T3 Code server and note its HTTP origin. +2. Add every project that Teams may target to T3 Code. +3. Create the same project alias file used by the Discord bridge: + + ```yaml + projects: + scanner: + workspaceRoot: /srv/projects/scanner + ``` + +4. Set: + + ```dotenv + T3_HTTP_BASE_URL=http://127.0.0.1:3773 + T3_PROJECT_ALIASES_PATH=/etc/t3/project-aliases.yaml + T3_WEB_UI_BASE_URL=https://t3.example.com + T3_DISCORD_BOT_DATA_DIR=/var/lib/t3/teams-bridge + ``` + +`T3_DISCORD_BOT_DATA_DIR` retains its historical name but is also the durable data directory for +Teams-only deployments. + +## 2. Register the Microsoft application + +The fastest supported path is the current Teams Developer CLI: + +```bash +npm install --global @microsoft/teams.cli +teams login +teams app create \ + --name t3-code \ + --endpoint https://teams-bot.example.com/api/messages \ + --env /secure/path/teams.env +``` + +The public endpoint must use HTTPS and route to the bridge process on `TEAMS_PORT` (3978 by +default). The CLI creates the application/bot registration and writes the client, tenant, and +secret values. An equivalent Azure Bot + Entra app registration created in the portals also works. + +Set the runtime variables using the values from the registration: + +```dotenv +TEAMS_NATIVE_ENABLED=1 +TEAMS_CLIENT_ID=00000000-0000-0000-0000-000000000000 +TEAMS_CLIENT_SECRET=replace-with-secret-value +TEAMS_TENANT_ID=00000000-0000-0000-0000-000000000000 +TEAMS_PORT=3978 +TEAMS_MESSAGING_ENDPOINT=/api/messages +TEAMS_DEFAULT_PROJECT_SHORT_NAME=scanner +``` + +Do not set `DISCORD_BOT_TOKEN` in a Teams-only deployment. + +## 3. Configure project mapping by Teams channel + +Copy `apps/discord-bot/teams.channels.example.json` and map Teams locations to T3 aliases: + +```json +{ + "channels": [ + { + "teamId": "19:team-id@thread.tacv2", + "channelId": "19:channel-id@thread.tacv2", + "channelName": "Production incidents", + "projectShortName": "scanner", + "deliveryMode": "native", + "company": "Example", + "environment": "production", + "companyKeywords": ["example"], + "environmentKeywords": ["prod", "production"], + "automaticAssessmentEnabled": false + } + ] +} +``` + +Set `TEAMS_CHANNELS_PATH` to that file. Personal and group chats use +`TEAMS_DEFAULT_PROJECT_SHORT_NAME` when no channel mapping matches. + +Delivery modes: + +- `native`: native Teams activity handling owns replies. The Graph poller may still supply + background triggers, but it does not create Discord threads. +- `t3-only`: Graph triggers start T3 directly and optionally acknowledge through + `teamsIncomingWebhookUrl`. +- `discord`: compatibility mode; `discordChannelId` is required and the result is mirrored to + Discord. + +## 4. Build the Teams app package + +Start from `apps/discord-bot/teams-app/manifest.json`. + +1. Replace `${TEAMS_APP_ID}` and `${TEAMS_CLIENT_ID}` with the application/client ID. +2. Replace `${PUBLIC_BASE_URL}` and `${PUBLIC_HOST}` with the public HTTPS origin and host. +3. Add `outline.png` (32×32 transparent) and `color.png` (192×192). +4. Zip the three files at the archive root: + + ```text + manifest.json + outline.png + color.png + ``` + +The manifest enables personal, team, and group-chat bot scopes plus the +**Start T3 investigation** message action. + +## 5. Install for full Teams replacement + +1. Upload the app package through **Teams Admin Center → Teams apps → Manage apps**, or sideload it + while testing. +2. Allow the app in the applicable app permission policy. +3. Install it for users and in each team/group chat where it must answer. +4. In a channel, mention the bot and send a task. In personal chat, send the task directly. +5. Verify that Teams receives a “Started T3” acknowledgement and then the final T3 answer. +6. Verify controls: + + ```text + stop + approve + deny + ``` + +The native entrypoint is: + +```bash +pnpm --filter @t3tools/discord-bot start:teams +``` + +For a systemd/container deployment, expose only `TEAMS_PORT` through the HTTPS reverse proxy and +keep the T3 server/data paths private. + +## 6. Enable the message action without bot participation + +The same app package includes a bot-based message extension. The app must be available to the user, +but the conversational bot does not have to be added to the source channel/chat. + +1. Make the app available through an app setup policy or let the user add the app personally. +2. Open the **…** menu on a Teams message. +3. Choose **Start T3 investigation**. +4. Select the T3 project alias and optionally add instructions. +5. Submit. Teams shows a private confirmation while the service starts the T3 thread. +6. Follow the result in T3 Code. + +This route is appropriate where adding an active bot participant to customer or incident channels +is not yet approved. + +## 7. Optional Graph polling without a bot participant + +Set `TEAMS_ENABLED=1` in a combined deployment and grant the Entra application the chosen Graph +read permission. Prefer resource-specific consent (`ChannelMessage.Read.Group`) when monitoring a +small set of teams; broader application permissions require tenant admin consent and a wider +security review. + +The poller supports: + +- automatic problem-report assessment; +- allowlisted reaction triggers; +- allowlisted tag triggers; +- explicit configured display-name mentions; +- recent history and hosted image retrieval. + +Use `deliveryMode: "t3-only"` to ensure the poller never creates or requires a Discord thread. + +## 8. Production checklist + +- Public HTTPS endpoint resolves to `TEAMS_MESSAGING_ENDPOINT`. +- Inbound activity authentication is enabled; never set SDK `skipAuth` in production. +- Client secret is stored in a secret manager and rotated, or replace it with managed identity. +- `T3_PROJECT_ALIASES_PATH` contains every exposed alias and no unintended workspace. +- Teams app policies limit who can install/use the app. +- Graph permissions use the narrowest viable consent model. +- Durable bridge data is backed up. +- T3 and Teams bridge logs/OTLP traces are monitored. +- A test message verifies start, continuation, final delivery, stop, approval, and message action. +- Discord is disabled by omitting `DISCORD_BOT_TOKEN` when Teams is the sole transport. -This repository now includes a Teams intake path inside [`apps/discord-bot`](apps/discord-bot). +## Microsoft references -It does three things: - -1. Polls a configured list of Teams channels through Microsoft Graph. -2. Detects one or more configured trigger modes per channel. -3. Creates or reuses a Discord thread, starts a T3 investigation thread, and streams the analysis back into Discord. - -## What The Module Does - -- Each Teams channel is configured with: - - the Teams `teamId` and `channelId` - - the target Discord channel id - - the T3 project short name - - company and environment labels - - company/environment/problem keywords for matching -- Each channel can enable any combination of these escalation modes: - - automatic assessment of new messages for German problem reports - - allowlisted internal-user reactions, such as `eyes` or `🚨` - - allowlisted internal-user tag messages, such as `#investigate`, typically sent as a reply to the message being escalated -- Explicit Teams `@mention` traffic is treated as a forced trigger on top of those modes. -- Automatic assessment only runs on newly seen messages. -- Reaction triggers are re-evaluated on already-seen messages, so an internal user can flag an older message later without reposting it. -- Tag triggers are only accepted from configured internal user ids on a per-channel basis. -- The first detected message for a Teams thread opens a Discord thread under the mapped Discord channel. -- Later matching messages in the same Teams thread continue the same T3 thread instead of opening duplicates. -- Any image attachments on the target Teams message are copied into the Discord seed message and passed into the T3 investigation turn. -- The intake prompt also includes immediate preceding channel history from the prior two hours, stopping at the previous already-investigated Teams root. - -## Trigger Configuration - -Per-channel configuration supports these fields: - -- `automaticAssessmentEnabled` - - Default: `true` - - Enables German problem-report assessment for newly seen messages in that channel. -- `internalUserIds` - - Microsoft Entra user ids allowed to trigger manual escalations in that channel. -- `reactionTriggerTypes` - - Reaction types accepted from `internalUserIds`, for example `["eyes", "🚨"]`. -- `messageTagTriggers` - - Text markers accepted from `internalUserIds`, for example `["#investigate", "#triage"]`. - -You can enable one, two, or all three of those modes per channel. - -## Current Limits - -- The implementation uses Microsoft Graph application auth for reading channel messages. -- For write-back to Teams, the implementation only supports an optional channel webhook acknowledgement. -- It does **not** implement a full Bot Framework HTTPS endpoint yet. -- Because of that, “respond to @ trigger messages” currently means: - - detect the mention - - create the Discord/T3 incident thread - - optionally post a short acknowledgement back to Teams through a configured incoming webhook -- If you need native threaded replies in Teams as the bot, add a Bot Framework endpoint later. - -## Config Files - -Set these env vars for the Discord bot process: - -- `TEAMS_ENABLED=1` -- `TEAMS_TENANT_ID=` -- `TEAMS_CLIENT_ID=` -- `TEAMS_CLIENT_SECRET=` -- `TEAMS_CHANNELS_PATH=/absolute/path/to/teams.channels.json` -- `TEAMS_POLL_INTERVAL_SECONDS=60` -- `TEAMS_BOT_DISPLAY_NAME=T3 Code` - -Use [`apps/discord-bot/teams.channels.example.json`](apps/discord-bot/teams.channels.example.json) as the starting point. - -## Polling Window - -- On normal weekdays, the poller scans the last 24 hours of channel traffic. -- On Saturday, Sunday, and Monday, it scans from Friday 00:00 UTC onward so weekend reports are still in scope on Monday morning. - -## Microsoft Setup - -The setup depends on whether you only need scan-and-escalate, or also want native in-Teams bot replies. - -### Minimum Setup For This Module - -This is enough for the code currently in the repo. - -1. Create a Microsoft Entra app registration. -2. Add a client secret. -3. Grant Microsoft Graph application permissions needed to read channel messages. -4. Grant admin consent in the tenant. -5. Install the Teams app where you want resource-specific consent, if you use RSC-scoped access. -6. Create one Teams incoming webhook per channel only if you want acknowledgements posted back to Teams. - -### Permissions And Consent - -From Microsoft Learn: - -- Teams bots only receive channel messages by default when directly mentioned. -- To receive all channel messages without mentions, Teams supports resource-specific consent with `ChannelMessage.Read.Group`. -- Microsoft Graph permissions that expose organization-wide data require admin consent in Microsoft Entra. -- Teams app admins can review both Microsoft Graph permissions and RSC permissions in the Teams admin center. - -Operationally, that means: - -- If you want least-privilege per team/channel behavior, use Teams app installation plus RSC. -- If your tenant instead grants broader Graph application permissions, your security review should treat that as tenant-wide read access. - -## Recommended Org Approval Path - -1. Security reviews the Entra app registration and the exact Graph/RSC permissions requested. -2. Teams admins verify the app in Teams admin center and review the Permissions tab. -3. Team owners approve installation into the specific teams/channels that should be monitored. -4. Discord admins provide the destination Discord channel ids. -5. Operators add the channel mappings JSON and restart the bot. - -## FAQ: Can We Reuse One Team Member's Credentials? - -Technically, yes, for very basic delegated-access scenarios. In that model the bot acts on behalf of a real user instead of using app-only service credentials. - -That is not the recommended setup for this module. - -Main downsides: - -- Reliability: - - the integration breaks if that user changes password, loses access, leaves the company, or is affected by MFA or Conditional Access changes -- Security: - - the bot now depends on a human credential or long-lived delegated refresh token, which is a worse secret to protect than a service credential -- Auditability: - - reads and writes are attributed to a person rather than a service identity -- Access control: - - the effective scope becomes whatever that user can access, which is often broader and less explicit than a dedicated app registration -- Operability: - - Microsoft discourages username/password automation flows such as ROPC, and those flows are incompatible with common MFA-based tenant setups - -Use a real Entra app registration with app-only auth for the poller. - -If you later need a user-driven trigger that should feel native inside Teams, use a proper Teams app or message action flow instead of storing one employee's credentials in the bot. - -## Native Teams Bot Replies - -If you later need true bot replies inside Teams threads, you will need more than this module currently ships: - -1. A Teams app manifest with bot scope for teams/channels. -2. A Bot Framework or Teams AI endpoint reachable from Microsoft over HTTPS. -3. Bot authentication and token validation for incoming activities. -4. Channel installation in each target team/channel. - -That is a separate step because it changes the runtime shape from “poller” to “internet-facing bot service”. - -## Microsoft References - -- Receive all channel messages for bots and agents: - https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-messages-for-bots-and-agents -- Channel and group chat conversations with a bot: - https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations -- Resource-specific consent: - https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/resource-specific-consent -- Teams app permissions and consent: - https://learn.microsoft.com/en-us/microsoftteams/app-permissions -- Apps for shared and private channels: - https://learn.microsoft.com/en-us/microsoftteams/platform/build-apps-for-shared-private-channels -- Microsoft Graph permissions reference: - https://learn.microsoft.com/en-us/graph/permissions-reference +- [Teams SDK quickstart and app registration](https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/get-started/quickstart-register) +- [Teams bot conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) +- [Proactive message installation requirements](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +- [Message-extension action dialogs](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/create-task-module) +- [Resource-specific consent](https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/resource-specific-consent) +- [Receive channel/chat messages using RSC](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-messages-for-bots-and-agents) diff --git a/docs/integrations/microsoft-teams-message-action-plan.md b/docs/integrations/microsoft-teams-message-action-plan.md index b374398a4d5..357a6658beb 100644 --- a/docs/integrations/microsoft-teams-message-action-plan.md +++ b/docs/integrations/microsoft-teams-message-action-plan.md @@ -1,186 +1,19 @@ -# Microsoft Teams Message Action Plan +# Microsoft Teams message action -This document describes the next step after the polling-based Teams intake module: a native Teams message action that lets an internal user trigger investigation from the message UI instead of relying on visible channel tags. +Status: implemented. -It is intended to stack on top of the polling implementation in [`docs/integrations/microsoft-teams-discord-bot.md`](/var/lib/t3/worktrees/t3code/t3-discord-87fc50be/docs/integrations/microsoft-teams-discord-bot.md). +The Teams app manifest declares **Start T3 investigation** as an action command in the `message` +context. `TeamsNativeApp` handles: -## Why This Mode +- `composeExtension/fetchTask` by returning an Adaptive Card dialog; +- project alias and optional instruction input; +- `composeExtension/submitAction` by starting a transport-neutral T3 turn; +- a private Teams confirmation without installing the conversational bot in the source + conversation. -The current poller already supports three low-friction triggers: +The action uses the selected Teams message as the investigation prompt and keys deduplication by +tenant plus source-message ID. It deliberately does not attempt to post a later result into a +conversation where the bot is not installed; users follow the T3 thread in T3 Code. -- automatic German problem detection -- allowlisted internal-user reactions -- allowlisted internal-user tag messages - -Those are good short-term options, but a native Teams message action is a better end state because it: - -- avoids noisy visible tags in the channel -- keeps the trigger close to the source message -- makes authorization easier to explain to internal users -- gives us room for a confirmation dialog before opening a Discord/T3 investigation - -## Recommended Product Shape - -Use a bot-based Teams message extension with an action command available in the `message` context. - -Recommended command name: - -- `Start investigation` - -Optional second command later: - -- `Start investigation with note` - -Why bot-based: - -- Teams action commands for message actions are supported through bot-based message extensions. -- This keeps us compatible with the existing bot-style workflow and gives us room for richer dialog handling later. - -## User Flow - -1. An internal user opens the message action menu on a Teams message. -2. The user selects `Start investigation`. -3. Phase 1 can immediately submit the message payload without a form. -4. The bot service validates the user and channel against our allowlists. -5. The service resolves the source message, recent history, and image attachments. -6. The existing Teams-to-Discord intake pipeline is invoked with reason `message-action`. -7. Teams returns a lightweight confirmation to the triggering user. -8. Discord receives or reuses the linked thread and starts the T3 investigation. - -## Architecture - -Add a small internet-facing Teams bot service next to the current poller. - -Core pieces: - -- Teams app manifest with: - - `bot` - - `composeExtensions` - - action command in `message` context -- Microsoft Bot registration with Teams channel enabled -- HTTPS endpoint that receives Bot Framework invoke activities for the message extension -- shared intake service that both the poller and the message action path call - -Recommended internal split: - -- Keep Graph polling in the current module. -- Extract intake orchestration into a shared service, for example `TeamsIntakeRouter`. -- Let both: - - the poller - - the message action webhook - call the same `start investigation` path. - -That avoids duplicating: - -- Discord thread lookup/creation -- attachment copying -- history collection -- dedupe rules -- T3 turn startup - -## Auth And Permissions - -### Teams App / Bot - -Required: - -- Microsoft Entra app registration for the Teams app/bot identity -- Bot Framework registration -- Teams channel enabled for the bot -- public HTTPS endpoint with Bot Framework auth/token validation - -### Graph Access - -The message action payload gives message context, but the service should still fetch canonical message data through Microsoft Graph before escalation so that it can: - -- pull image attachments and hosted content -- collect recent history -- apply channel configuration and dedupe rules consistently - -This means the message action path still depends on Graph application permissions or resource-specific consent, the same way the poller does. - -### Allowlisting - -The server should enforce all of these before starting an investigation: - -- triggering Teams user id is allowlisted for that channel -- target Teams channel is configured -- source message root has not already been processed - -## Organizational Approval - -Compared with the current poller-only setup, the message action path adds a second approval surface: - -1. Security review for the Entra app registration and Graph permissions. -2. Teams admin review for the app manifest, bot capability, and message extension capability. -3. Bot service review because the app now exposes a public HTTPS endpoint. -4. Team-owner rollout approval for installation into the specific teams/channels. - -Operationally, the main extra review item is that this is no longer just a background poller. It becomes an interactive Teams app with an externally reachable bot endpoint. - -## Proposed Delivery Phases - -### Phase 1 - -Goal: single-click message action without extra dialog fields. - -Scope: - -- one `Start investigation` message action -- allowlisted users only -- channel allowlist only -- reuse existing Discord/T3 intake behavior -- ephemeral confirmation back to Teams - -### Phase 2 - -Goal: optional operator note. - -Scope: - -- task module or dialog with one optional text field -- note is added to the T3 prompt and Discord seed - -### Phase 3 - -Goal: operational hardening. - -Scope: - -- audit logging for who triggered what -- clearer Teams-side confirmation including Discord thread link if feasible -- retry and idempotency handling for duplicate invoke deliveries - -## Implementation Plan - -1. Extract the current Teams escalation logic from `TeamsModule` into a reusable service. -2. Extend the intake reason model to include `message-action`. -3. Add a minimal Teams app manifest package in the repo for admin review. -4. Add a bot webhook route that validates Bot Framework requests. -5. Parse the message action invoke payload and map it to: - - Teams user id - - team id - - channel id - - target message id -6. Fetch the canonical message plus recent history and hosted contents through Graph. -7. Reuse the shared intake router to create or continue the Discord/T3 investigation. -8. Return a simple success/failure response to Teams. -9. Document deployment, app upload, admin approval, and per-channel enablement. - -## Open Questions - -- Do we want the message action to be installed tenant-wide but only enabled by config, or only installed into selected teams? -- Should message action be limited to root messages, or allowed on replies as well? -- Do we want the confirmation response to include a Discord link, or keep it minimal? -- Should message action use the same dedupe key as reactions and auto-detection, or intentionally allow a forced retrigger option later? - -## Microsoft References - -- Build message extensions: - https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/what-are-messaging-extensions -- Build bot-based message extensions: - https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/build-bot-based-message-extension -- Define action commands: - https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command -- Teams SDK action commands guide: - https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/in-depth-guides/message-extensions/action-commands +See [Deploying the T3 Code Microsoft Teams integration](./microsoft-teams-discord-bot.md) for +registration, packaging, installation, and rollout steps. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddc638fb9a3..c7cb1a5c3b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@microsoft/teams.apps': + specifier: 2.0.14 + version: 2.0.14 '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -1250,6 +1253,14 @@ packages: resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@azure/msal-common@16.11.2': + resolution: {integrity: sha512-yDhtBOGDCdK9ipQ9g3+wmlMEPnZx2pXaDicDd9jYyR1L+7lEbvEohTDmF5qejZDutZY3m9pWPxeYxzNC701A2w==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.4.2': + resolution: {integrity: sha512-fnqOpGYAV+i0RH4W5HB6Oy1IhqGZoCdnp7Y2Sa9k18FlT8aBkCA7L8Hv19hUHLDUK6kVjUO29AfnGX6wgAHyNg==} + engines: {node: '>=20'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -3163,6 +3174,26 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@microsoft/teams.api@2.0.14': + resolution: {integrity: sha512-mEHBgs7yklydQWTlfGFok5i7np2WxOKS3j6aMgRH0HVfiLqenPjGm0NAWKR/Hg29n9ODtGZZeuIXNCwIYQ8syA==} + engines: {node: '>=20'} + + '@microsoft/teams.apps@2.0.14': + resolution: {integrity: sha512-7re8a3pnAUSD5rAGlWbKQd07XtQJwyuZ4rI7EWyqBQT2iLhqUU1r4jtq3jQFpfov7xhADVV+oExCc7IbNyYuGw==} + engines: {node: '>=20'} + + '@microsoft/teams.cards@2.0.14': + resolution: {integrity: sha512-qrWnjJVgMPO8vvbLEiTeBLrrRLsr4rletQnuP9fPMXTTcrNx+Dh0gYWtQAOBvpoAuFo5tTXR/waNFNUm8IZtBw==} + engines: {node: '>=20'} + + '@microsoft/teams.common@2.0.14': + resolution: {integrity: sha512-cjf3b/2sr5RqqiOaDuogJQ9fUNRZjDSh9eBEAv6NqkigNNLLjDgEgEl5lLoJsPp4usvc3drwFkx6HayuEbT7vA==} + engines: {node: '>=20'} + + '@microsoft/teams.graph@2.0.14': + resolution: {integrity: sha512-PufhROXKhsC/h6ZKuquPzvNBB1Q9lHHu//vZGfRj3yD+hgtQkEz2YzXZDYC3h0bDN4ej2KtvkDGrsovqrnU0cQ==} + engines: {node: '>=20'} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -4897,6 +4928,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -5329,6 +5363,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -5543,6 +5581,9 @@ packages: aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -5706,6 +5747,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -6399,6 +6443,9 @@ packages: duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -7026,6 +7073,15 @@ packages: flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + fontace@0.4.1: resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} @@ -7292,6 +7348,10 @@ packages: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -7526,6 +7586,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} @@ -7599,9 +7662,27 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jwks-rsa@3.2.2: + resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} + engines: {node: '>=14'} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -7868,20 +7949,47 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + locate-path@3.0.0: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} @@ -7924,6 +8032,9 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lru-memoizer@2.3.0: + resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} + lru.min@1.1.4: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} @@ -8922,6 +9033,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -9214,6 +9329,9 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + reftools@1.1.9: resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==} @@ -11012,6 +11130,13 @@ snapshots: '@aws/lambda-invoke-store@0.2.4': {} + '@azure/msal-common@16.11.2': {} + + '@azure/msal-node@5.4.2': + dependencies: + '@azure/msal-common': 16.11.2 + jsonwebtoken: 9.0.3 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -13322,6 +13447,49 @@ snapshots: transitivePeerDependencies: - supports-color + '@microsoft/teams.api@2.0.14': + dependencies: + '@microsoft/teams.cards': 2.0.14 + '@microsoft/teams.common': 2.0.14 + jwt-decode: 4.0.0 + qs: 6.15.3 + transitivePeerDependencies: + - debug + - supports-color + + '@microsoft/teams.apps@2.0.14': + dependencies: + '@azure/msal-node': 5.4.2 + '@microsoft/teams.api': 2.0.14 + '@microsoft/teams.common': 2.0.14 + '@microsoft/teams.graph': 2.0.14 + axios: 1.18.1 + cors: 2.8.6 + express: 5.2.1 + jsonwebtoken: 9.0.3 + jwks-rsa: 3.2.2 + reflect-metadata: 0.2.2 + transitivePeerDependencies: + - debug + - supports-color + + '@microsoft/teams.cards@2.0.14': {} + + '@microsoft/teams.common@2.0.14': + dependencies: + axios: 1.18.1 + transitivePeerDependencies: + - debug + - supports-color + + '@microsoft/teams.graph@2.0.14': + dependencies: + '@microsoft/teams.common': 2.0.14 + qs: 6.15.3 + transitivePeerDependencies: + - debug + - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.27) @@ -15112,6 +15280,11 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.12.4 + '@types/keyv@3.1.4': dependencies: '@types/node': 24.12.4 @@ -15485,6 +15658,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} agent-install@0.0.5: @@ -15830,6 +16009,16 @@ snapshots: aws4fetch@1.0.20: {} + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + axobject-query@4.1.0: {} babel-dead-code-elimination@1.0.12: @@ -16087,6 +16276,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} bufferutil@4.1.0: @@ -16634,6 +16825,10 @@ snapshots: dependencies: readable-stream: 2.3.8 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488): @@ -17662,6 +17857,8 @@ snapshots: flow-enums-runtime@0.0.6: {} + follow-redirects@1.16.0: {} + fontace@0.4.1: dependencies: fontkitten: 1.0.3 @@ -18039,6 +18236,13 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -18254,6 +18458,8 @@ snapshots: jiti@2.7.0: {} + jose@4.15.9: {} + jose@6.2.2: {} jose@6.2.3: {} @@ -18311,6 +18517,19 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -18318,6 +18537,29 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwks-rsa@3.2.2: + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3 + jose: 4.15.9 + limiter: 1.1.5 + lru-memoizer: 2.3.0 + transitivePeerDependencies: + - supports-color + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + jwt-decode@4.0.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -18515,17 +18757,35 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + limiter@1.1.5: {} + locate-path@3.0.0: dependencies: p-locate: 3.0.0 path-exists: 3.0.0 + lodash.clonedeep@4.5.0: {} + lodash.debounce@4.0.8: {} lodash.escaperegexp@4.1.2: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + lodash.throttle@4.1.1: {} lodash@4.18.1: {} @@ -18561,6 +18821,11 @@ snapshots: dependencies: yallist: 4.0.0 + lru-memoizer@2.3.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 6.0.0 + lru.min@1.1.4: {} lru_map@0.4.1: {} @@ -19861,6 +20126,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -20358,6 +20625,8 @@ snapshots: dependencies: redis-errors: 1.2.0 + reflect-metadata@0.2.2: {} + reftools@1.1.9: {} regenerate-unicode-properties@10.2.2: From a76f8424ed43ce7b35dd25d4949e5e2e57fcb210 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:01:20 +0200 Subject: [PATCH 4/4] feat(teams): surface pending T3 interactions --- .../src/features/TeamsNativeApp.ts | 63 ++++++++++++++++++- apps/discord-bot/teams-app/manifest.json | 4 ++ .../microsoft-teams-discord-bot.md | 2 + 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/apps/discord-bot/src/features/TeamsNativeApp.ts b/apps/discord-bot/src/features/TeamsNativeApp.ts index 6fdc7b5352d..e2bb1fc23bd 100644 --- a/apps/discord-bot/src/features/TeamsNativeApp.ts +++ b/apps/discord-bot/src/features/TeamsNativeApp.ts @@ -1,9 +1,11 @@ // @effect-diagnostics anyUnknownInErrorContext:off globalErrorInEffectCatch:off globalErrorInEffectFailure:off globalPromise:off missingEffectContext:off tryCatchInEffectGen:off preferSchemaOverJson:off import { App } from "@microsoft/teams.apps"; -import type { ThreadId } from "@t3tools/contracts"; +import { ProviderUserInputAnswers, type ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import type { DiscordBotConfig } from "../config.ts"; +import { derivePendingInteractions } from "../presentation/pendingInteractions.ts"; import { ProjectAliasStore } from "../projectAliases.ts"; import { ThreadLinkStore } from "../store/ThreadLinkStore.ts"; import { T3Session } from "../t3/T3Session.ts"; @@ -75,6 +77,7 @@ const waitForFinalAnswer = Effect.fn("waitForFinalAnswer")(function* (input: { readonly send: (text: string) => Promise; }) { const t3 = yield* T3Session; + const announcedRequests = new Set(); while (true) { yield* Effect.sleep("1 second"); const snapshot = yield* t3.fetchThreadDetail(input.t3ThreadId).pipe( @@ -86,6 +89,47 @@ const waitForFinalAnswer = Effect.fn("waitForFinalAnswer")(function* (input: { ), ); if (snapshot === null) continue; + for (const interaction of derivePendingInteractions(snapshot.thread.activities)) { + if (announcedRequests.has(interaction.requestId)) continue; + announcedRequests.add(interaction.requestId); + if (interaction.kind === "approval") { + yield* Effect.tryPromise({ + try: () => + input.send( + [ + `T3 needs **${interaction.requestKind}** approval.`, + interaction.detail, + `Reply \`approve ${interaction.requestId}\` or \`deny ${interaction.requestId}\`.`, + ] + .filter((line): line is string => line !== null) + .join("\n\n"), + ), + catch: (cause) => new Error(String(cause)), + }); + } else { + const questions = interaction.questions + .map( + (question) => + `- \`${question.id}\`: ${question.question}${ + question.options.length === 0 + ? "" + : ` (${question.options.map((option) => option.label).join(", ")})` + }`, + ) + .join("\n"); + yield* Effect.tryPromise({ + try: () => + input.send( + [ + "T3 needs more information:", + questions, + `Reply \`answer ${interaction.requestId} {"question-id":"answer"}\`.`, + ].join("\n\n"), + ), + catch: (cause) => new Error(String(cause)), + }); + } + } const answer = finalAnswerText(snapshot.thread); const latestTurnId = snapshot.thread.latestTurn?.turnId ?? null; if ( @@ -225,6 +269,23 @@ export const runTeamsNativeApp = Effect.fn("runTeamsNativeApp")(function* ( return; } + const userInput = /^(?:\/?)answer\s+(\S+)\s+(.+)$/isu.exec(prompt); + if (userInput !== null) { + if (existing === null) { + yield* Effect.promise(() => + reply("There is no linked T3 thread for that input request."), + ); + return; + } + const parsed = yield* Effect.try({ + try: () => JSON.parse(userInput[2]!) as unknown, + catch: () => new Error("Answers must be a JSON object."), + }).pipe(Effect.flatMap(Schema.decodeUnknownEffect(ProviderUserInputAnswers))); + yield* t3.respondToUserInput(existing.t3ThreadId, userInput[1]!, parsed); + yield* Effect.promise(() => reply("Submitted the requested input to T3.")); + return; + } + if (prompt.length === 0) return; if (projectShortName === undefined) { yield* Effect.promise(() => diff --git a/apps/discord-bot/teams-app/manifest.json b/apps/discord-bot/teams-app/manifest.json index 5b3eb7bbf64..14b6f570bfa 100644 --- a/apps/discord-bot/teams-app/manifest.json +++ b/apps/discord-bot/teams-app/manifest.json @@ -45,6 +45,10 @@ { "title": "deny", "description": "Deny a pending T3 request by request id." + }, + { + "title": "answer", + "description": "Answer a T3 user-input request with a JSON object." } ] } diff --git a/docs/integrations/microsoft-teams-discord-bot.md b/docs/integrations/microsoft-teams-discord-bot.md index afb62fb4cf9..533a1063646 100644 --- a/docs/integrations/microsoft-teams-discord-bot.md +++ b/docs/integrations/microsoft-teams-discord-bot.md @@ -22,6 +22,7 @@ tag triggers. Its per-channel `deliveryMode` can be `discord`, `t3-only`, or `na | Final answer posted to Teams | Yes | No; follow in T3 Code | Optional workflow webhook acknowledgement | | Stop a turn | `stop` | In T3 Code | In T3 Code | | Approve/deny | `approve ` / `deny ` | In T3 Code | In T3 Code | +| Answer requested input | `answer {"id":"answer"}` | In T3 Code | In T3 Code | | Automatic/reaction/tag triggers | Optional Graph poller | Explicit message action | Yes | Microsoft requires a Teams app to be installed in a team or group chat before its bot can send @@ -150,6 +151,7 @@ The manifest enables personal, team, and group-chat bot scopes plus the stop approve deny + answer {"question-id":"answer"} ``` The native entrypoint is: