From 062c9258660278033a54cf5cdc5992f6ba83c9fc Mon Sep 17 00:00:00 2001 From: Samuel McAravey Date: Sat, 9 May 2026 23:29:44 -0600 Subject: [PATCH] Add Telegram launchpad worktree support --- README.md | 9 +- docs/botfather.md | 64 +- docs/command-reference.md | 103 +++ docs/getting-started.md | 16 +- docs/manual-test-plan.md | 13 +- docs/menus.md | 2 + docs/operations.md | 2 +- docs/usage.md | 16 +- .../Configuration/InteractiveBootstrapMenu.cs | 2 + .../Incursa.Codex.Telegram.csproj | 13 +- .../Models/CodexDtos.cs | 25 +- src/Incursa.Codex.Telegram/Program.cs | 3 + .../Services/CodexGateway.cs | 46 ++ .../Services/CodexRuntimeState.cs | 8 +- .../Services/CodexSessionManager.cs | 44 ++ .../Services/CodexViewModelMapper.cs | 11 + .../Services/GitWorktreeProvisioner.cs | 194 +++++ .../Telegram/TelegramBotMetadata.cs | 89 +++ .../TelegramBotMetadataSyncService.cs | 164 +++++ .../Telegram/TelegramBotStateStore.cs | 85 ++- .../TelegramCodexBotCommandHandler.cs | 669 ++++++++++++++++-- .../Telegram/TelegramCodexBotHostedService.cs | 6 +- ...elegramLaunchpadExpirationHostedService.cs | 107 +++ .../Telegram/TelegramLaunchpadPolicy.cs | 40 ++ .../GitWorktreeProvisionerTests.cs | 88 +++ .../TelegramBotMetadataSyncServiceTests.cs | 96 +++ .../TelegramBotStateStoreTests.cs | 39 + .../TelegramCommandHandlerTests.cs | 379 +++++++++- ...amLaunchpadExpirationHostedServiceTests.cs | 69 ++ .../TelegramQueuedPromptProcessorTests.cs | 12 + 30 files changed, 2293 insertions(+), 121 deletions(-) create mode 100644 src/Incursa.Codex.Telegram/Services/GitWorktreeProvisioner.cs create mode 100644 src/Incursa.Codex.Telegram/Telegram/TelegramBotMetadata.cs create mode 100644 src/Incursa.Codex.Telegram/Telegram/TelegramBotMetadataSyncService.cs create mode 100644 src/Incursa.Codex.Telegram/Telegram/TelegramLaunchpadExpirationHostedService.cs create mode 100644 src/Incursa.Codex.Telegram/Telegram/TelegramLaunchpadPolicy.cs create mode 100644 tests/Incursa.Codex.Telegram.Tests/GitWorktreeProvisionerTests.cs create mode 100644 tests/Incursa.Codex.Telegram.Tests/TelegramBotMetadataSyncServiceTests.cs create mode 100644 tests/Incursa.Codex.Telegram.Tests/TelegramLaunchpadExpirationHostedServiceTests.cs diff --git a/README.md b/README.md index 21ab207..391e19a 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,10 @@ Keep the token private. Anyone with the token can control the bot account. Recommended BotFather settings for a first private-chat release: -1. Use `/setdescription` and `/setabouttext` to explain that this bot controls a local Codex installation. +1. Let the app sync its command list, description, short description, and conservative group-admin defaults automatically on startup. 2. Keep group joins disabled unless you intentionally want group support. 3. Keep privacy mode enabled unless you intentionally need ordinary group text routed to Codex. -4. Add commands later after the private-chat flow works. +4. Add a profile image manually if you want one. Copy-paste BotFather text, command lists, and privacy recommendations are in [BotFather setup](docs/botfather.md). @@ -339,12 +339,15 @@ After a successful test, you should see the transcription in Telegram before the | `/queue` | View queued prompts for the conversation, then edit, delete, or send one now. | | `/model` | Show or change the active session model. | | `/thinking` | Show or change reasoning effort. | +| `/goal` | Show, set, pause, resume, complete, or clear the active session goal. | | `/status` | Show active session status, including compact Codex usage when available. | | `/usage` | Show five-hour and weekly Codex usage, with reset times. | | `/tail [lines]` | Show recent output and keep following the session; defaults to 40 lines. | | `/outbound` | Inspect delayed or batched Telegram output. | | `/stop` | Gracefully stop a session. | | `/restart confirm` | Show standalone-process restart guidance. | +| `/launchpad on\|off\|status` | Arm or disarm root-chat launch mode for plain-text or audio launches; launchpad names lanes from the group title plus a sequence number. | +| `/launch [\| ]` | Create a detached git worktree-backed forum topic and Codex session while launchpad is armed. | | `/topic ...` | Manage forum-topic sessions in allowed supergroups. | For a fuller operator guide, see [docs/usage.md](docs/usage.md). @@ -394,7 +397,7 @@ Groups and forum topics require: 3. BotFather privacy settings that match the desired behavior. 4. Topic-management rights if the bot should create forum topics. -Start privately first. Then use a trusted group root as a single project/session lane, or use forum topics when one group needs multiple independent sessions. +Start privately first. Then use a trusted group root as a single project/session lane, use launchpad mode when you want to fan out repeated plain-text, audio, or `/launch` topic launches quickly, or use forum topics when one group needs multiple independent sessions. ## Support And Security diff --git a/docs/botfather.md b/docs/botfather.md index 6ebdfa3..62961d2 100644 --- a/docs/botfather.md +++ b/docs/botfather.md @@ -40,9 +40,11 @@ The username must be unique and must end in `bot`. After BotFather returns the token, store it in the app setup menu, `appsettings.Local.json`, user secrets, or environment variables. Do not paste the token into public issues, screenshots, docs, commits, or demo videos. +The app now syncs its command list, description, short description, and conservative group-admin defaults automatically on startup, so the manual BotFather commands below are only needed if you want to override the defaults yourself. + ## Set Description -The description appears when a user first opens the bot conversation. +The app now writes the description automatically on startup. Use this BotFather command only if you want to override the default text manually. Send this to BotFather: @@ -53,12 +55,12 @@ Send this to BotFather: Choose your bot, then paste: ```text -Talk to a local Codex CLI session from Telegram. This bot runs on the operator's machine, uses an explicit allowlist, and only works after local setup. +Talk to a local Codex installation from Telegram and route prompts to sessions. ``` ## Set About Text -The about text is a shorter profile summary. +The app now writes the short profile summary automatically on startup. Use this BotFather command only if you want to override the default text manually. Send this to BotFather: @@ -69,12 +71,12 @@ Send this to BotFather: Choose your bot, then paste: ```text -Private Telegram control surface for a local Codex CLI session. +Control local Codex sessions from Telegram. ``` ## Set Commands -The command list appears in Telegram's command picker when users type `/`. +The app now writes the command picker list automatically on startup. Use this BotFather command only if you want to override the default list manually. Send this to BotFather: @@ -85,29 +87,32 @@ Send this to BotFather: Choose your bot, then paste: ```text -help - Show supported commands -whoami - Show Telegram user, chat, and topic IDs -version - Show the running app version -trust - Trust the current group or forum chat -doctor - Diagnose authorization, routing, project, session, and queue state -projects - List known local projects -project - Select, add, or show the current project -new - Create and select a Codex session -sessions - List active and managed sessions -use - Select an existing session -send - Send text to the active session -steer - Steer the active turn -queue - View, edit, send, or delete queued prompts -model - Show or change model settings -thinking - Show or change thinking effort -tail - Show recent session output -status - Show session status and compact usage -usage - Show five-hour and weekly Codex usage -outbound - Show outbound Telegram queue status -stop - Stop the active or selected session -topic - Manage forum-topic sessions -topics - List topic/session bindings -restart - Show restart guidance +help - show this help +whoami - show Telegram user, chat, and topic thread IDs +version - show the running Codex Telegram app version +trust - trust the current group or forum chat for allowlisted users +projects - list known project directories +project - add or select a project +topics - list Telegram topics/sessions in this chat +topic - manage forum-topic sessions +launchpad - arm or disarm root-chat launch mode for plain-text and audio launches +launch - create a detached git worktree-backed forum topic and session while launchpad is armed +sessions - show active and Telegram-managed sessions +new - create and select a Codex session in the active project +use - select the active session for this conversation +send - send text to the active session +steer - steer the active turn in the selected session +queue - view, edit, delete, or send queued prompts now +model - show or change the selected session model +thinking - change the selected session thinking effort +goal - show or change the selected session goal +tail - show recent output and keep following the session live +status - show session status +usage - show Codex account usage remaining and reset times +doctor - explain authorization, routing, active project/session, workspace roots, and queue state +outbound - show outbound Telegram queue status +stop - gracefully stop a session +restart - show restart guidance ``` Command behavior: @@ -115,7 +120,8 @@ Command behavior: 1. Keep the command descriptions short; Telegram rejects invalid command definitions. 2. BotFather may take a few minutes to reflect command-list changes in every client. 3. Telegram's command picker lists top-level commands only, so `/queue edit`, `/queue delete`, and `/queue send` stay under the `/queue` entry. -4. `/kill`, `/rename`, and `/forget` are supported but intentionally omitted from the public command picker to keep the common menu simple. They remain documented in [command-reference.md](command-reference.md). +4. `/launchpad` and `/launch` are supported so the bot can spawn repeated forum-topic lanes from the group root without keeping launch mode on forever, and launchpad mode can also turn plain text or audio into a seeded launch while it is armed. Each launch provisions a detached git worktree-backed session lane. +5. `/kill`, `/rename`, and `/forget` are supported but intentionally omitted from the public command picker to keep the common menu simple. They remain documented in [command-reference.md](command-reference.md). ## Group Join Setting diff --git a/docs/command-reference.md b/docs/command-reference.md index d71a294..4bd617e 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -636,6 +636,67 @@ Expected behavior: 2. Leaves the model unchanged. 3. Returns the updated model settings. +### `/goal` + +Shows the current Codex goal for the active session. + +Syntax: + +```text +/goal +``` + +Expected behavior: + +1. Defaults to the active session. +2. Shows goal status, objective, token use when Codex reports it, elapsed goal time when Codex reports it, and last update age. +3. Explains how to set a goal when none is present. +4. Fails with clear setup text if the configured Codex backend or installed app-server does not expose thread goals. + +### `/goal [objective]` + +Sets a new active goal objective for the active session. + +Syntax: + +```text +/goal +/goal set +``` + +Examples: + +```text +/goal finish the release checklist and stop at the first real blocker +/goal set get /goal working in the Telegram app +``` + +Expected behavior: + +1. Sets the objective on the active Codex thread. +2. Marks the goal active. +3. Shows the updated goal. + +### `/goal clear|pause|resume|complete` + +Changes or clears the goal for the active session. + +Syntax: + +```text +/goal clear +/goal pause +/goal resume +/goal complete +``` + +Expected behavior: + +1. `/goal clear` removes the current goal. +2. `/goal pause` marks the current goal paused. +3. `/goal resume` marks the current goal active. +4. `/goal complete` marks the current goal complete. + ### Inline Model Control Phrase Sets model/thinking and sends a prompt in one message. @@ -800,6 +861,48 @@ Expected behavior: 2. Shows active session status. 3. Shows active project status when present. +### `/launchpad on|off|status` + +Arms or disarms the root chat for repeated launch commands and plain-text or audio launch messages. + +Syntax: + +```text +/launchpad on +/launchpad off +/launchpad status +``` + +Expected behavior: + +1. Works only from the root of a forum-enabled supergroup. +2. `/launchpad on` arms the root chat for 10 minutes of inactivity. +3. `/launchpad status` shows whether the root chat is armed, the remaining time, the active project, and the launch template session if one is selected. +4. `/launchpad off` clears the armed state immediately. +5. The bot auto-clears expired launchpad state and notifies the root chat when the timeout elapses. +6. While launchpad is armed, plain text or audio messages in the root chat create a new topic/session pair with a deterministic topic title based on the root chat name and a per-chat lane number, then seed the new session with that message text. +7. Launches provision detached git worktrees under the allowed workspace root so each lane has isolated on-disk work. + +### `/launch [| ]` + +Creates a new forum topic and matching Codex session while launchpad is armed, using a detached git worktree. + +Syntax: + +```text +/launch +/launch | +``` + +Expected behavior: + +1. Works only in the root of a forum-enabled supergroup while launchpad is armed. +2. Uses the supplied path when provided and allowed. +3. Provisions a detached git worktree under the allowed workspace root and seeds the new session with the launch name, with the forum topic title capped so long prompts do not become the topic name. +3. Otherwise uses the active project for the current conversation. +4. Creates a Telegram topic and Codex session, then binds them together. +5. Copies the current root session's model and thinking settings onto the launched session when a root session is selected; otherwise the launched session uses the normal Codex defaults. + ### `/topic new [| ]` Creates a new Telegram forum topic and matching Codex session. diff --git a/docs/getting-started.md b/docs/getting-started.md index 2af0de7..a0b222d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -67,13 +67,12 @@ Follow this sequence: 5. Copy the token BotFather gives you. 6. Keep that token private. Anyone with the token can control the bot. -Optional BotFather steps that are worth doing: +The app now syncs its command list, description, about text, and conservative group-admin defaults automatically on startup, so you usually do not need to set those by hand. -1. `/setdescription` to add a short explanation of what the bot does. -2. `/setabouttext` to add a slightly longer summary. -3. `/setuserpic` to give the bot a recognizable avatar. -4. `/setcommands` if you want Telegram's UI to show a command list. -5. `/revoke` if you ever leak the token and need to rotate it. +Optional BotFather steps that are still worth doing: + +1. `/setuserpic` to give the bot a recognizable avatar. +2. `/revoke` if you ever leak the token and need to rotate it. Copy-paste BotFather text, command lists, and recommended first-release settings are in [botfather.md](botfather.md). @@ -422,6 +421,7 @@ The supported flow is: 4. Optionally use `/topic new | ` to start the topic in a specific project. 5. Use `/topic attach [sessionId]` to bind the current topic to an existing session. 6. Use `/topic current` to see the active binding. +7. Use `/launchpad on` in the group root when you want to spawn several independent topics quickly; then send a plain-text or voice launch message, or use `/launch `, for each new detached worktree-backed lane. Launchpad titles the new topic from the group name plus a lane number so long prompts do not become the topic name. Behavior to remember: @@ -430,6 +430,7 @@ Behavior to remember: 3. If ordinary text appears to do nothing in a topic, use `/send ` or revisit privacy settings. 4. If Telegram rejects a reply to a stale, closed, or deleted topic, the bot does not retry the message in the group root. 5. The topic thread ID is useful when you want to debug or trace where messages are going. +6. Launchpad mode automatically turns off after 10 minutes of inactivity. ## Voice Notes, Audio, And Attachments @@ -467,6 +468,8 @@ For a complete parameter-by-parameter reference, see [command-reference.md](comm | `/project current` | Shows the current project binding. | Use to confirm what the conversation is anchored to. | | `/topics` | Lists Telegram topics and chat sessions in the conversation. | Use in chats with multiple topic threads. | | `/topic list` | Same as `/topics`. | Use whichever form is easiest to remember. | +| `/launchpad on` | Arms the group root for repeated plain-text or audio launches and `/launch` commands. | Use when you want to fan out multiple detached worktree-backed topic/session lanes quickly. | +| `/launch \| ` | Creates a new topic, session, and detached git worktree while launchpad is armed. | Use with or without an explicit project path. | | `/topic new ` | Creates a new forum topic and a new Codex session. | Use in a forum-enabled supergroup. | | `/topic new | ` | Creates a new topic and session in a specific project directory. | Use when you want the topic tied to a particular repo. | | `/topic attach [sessionId]` | Binds the current forum topic to an existing Codex session. | Use when you want a topic to resume work instead of creating a new session. | @@ -480,6 +483,7 @@ For a complete parameter-by-parameter reference, see [command-reference.md](comm | `/queue` | Shows queued prompts with Send now, Edit, and Delete buttons. | Use when you want to inspect or change prompts waiting behind an active turn. | | `/model [model] [thinking ]` | Shows or changes the selected session model. | Use when you need to switch the active model. | | `/thinking ` | Changes the reasoning effort for the selected session. | Use when you want more or less reasoning budget. | +| `/goal [objective|clear|pause|resume|complete]` | Shows or changes the selected session goal. | Use when you want Codex to keep a thread-level objective. | | `/tail [count]` | Shows recent output and keeps following the session. | Use while waiting on a live turn. | | `/status [sessionId]` | Shows session status and compact Codex usage when available. | Use when you want a quick health check. | | `/usage` | Shows five-hour and weekly Codex usage and reset times. | Use when planning around Codex usage blocks. | diff --git a/docs/manual-test-plan.md b/docs/manual-test-plan.md index 1e54a91..c898fe0 100644 --- a/docs/manual-test-plan.md +++ b/docs/manual-test-plan.md @@ -26,8 +26,9 @@ Record the date, operator, OS, published asset or commit SHA, Codex CLI version, 9. Send `/doctor` and confirm it explains access, routing, active project/session state, workspace roots, queue state, and a plausible next action. 10. Send `/new` and confirm the reply uses an auto-generated project-based session name without noisy status/model/thinking buttons; if Codex rate limits are available, confirm it includes a compact `Rate limits` line. 11. Send a normal text prompt and confirm live output returns to the private chat. -12. Send `/tail`, `/status`, `/usage`, `/model`, and `/thinking` and confirm each command is understandable without stale or truncated buttons; `/status`, `/model`, and `/thinking` should include compact rate limits when available. -13. Send `/stop` and confirm pending queued messages for the session are cleared. +12. Send `/tail`, `/status`, `/usage`, `/model`, `/thinking`, and `/goal` and confirm each command is understandable without stale or truncated buttons; `/status`, `/model`, and `/thinking` should include compact rate limits when available. +13. Send `/goal finish the private-chat smoke test`, then `/goal`, `/goal pause`, `/goal resume`, and `/goal clear`; confirm each reply reflects the expected objective or status when the installed Codex app-server supports thread goals. +14. Send `/stop` and confirm pending queued messages for the session are cleared. ## Authorization @@ -48,9 +49,11 @@ Record the date, operator, OS, published asset or commit SHA, Codex CLI version, 6. If ordinary group text is part of the demo, disable privacy mode in BotFather, re-add the bot if needed, and confirm ordinary text routes only in the intended trusted group or topic. 7. In a forum-enabled supergroup, run `/topic new ` with the bot missing topic-management rights and confirm the error is understandable. 8. Grant the needed topic rights and rerun `/topic new `. -9. Send messages in two topics and confirm each topic remains bound to its own session. -10. Close, delete, or otherwise invalidate a test topic when practical and confirm topic-scoped output is not retried in the group root. -11. Restart the process and confirm topic/session bindings rehydrate from local state. +9. Arm the group root with `/launchpad on`, send a plain-text or voice launch message from the root, and confirm a new topic/session pair is created, seeded from the root chat, and provisioned in a detached worktree. +10. Launch from a subdirectory project path and confirm the new session keeps the matching relative subpath inside the detached worktree. +11. Send messages in two topics and confirm each topic remains bound to its own session. +12. Close, delete, or otherwise invalidate a test topic when practical and confirm topic-scoped output is not retried in the group root. +13. Restart the process and confirm topic/session bindings rehydrate from local state. ## Voice And Attachments diff --git a/docs/menus.md b/docs/menus.md index 40db1b4..ae4a565 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -209,6 +209,8 @@ Common commands: ```text /topic new docs polish +/launchpad on +/launch docs polish /topic current /topic attach /topics diff --git a/docs/operations.md b/docs/operations.md index a56733b..3e3a481 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -64,7 +64,7 @@ For groups and forum topics: 1. Add only trusted users to `TelegramBot:AllowedUserIds`. 2. Trust the group with `/trust` from an allowlisted admin account, or add the group chat ID to `TelegramBot:AllowedChatIds`. 3. Keep Telegram privacy mode enabled unless ordinary group-root text should route to Codex. -4. Grant topic-management rights only if `/topic new` is part of the supported workflow. +4. Grant topic-management rights only if `/topic new`, `/launch`, or launchpad launch mode is part of the supported workflow. Group and forum messages require both an allowed user and a trusted chat. diff --git a/docs/usage.md b/docs/usage.md index 939e6ec..cb0f8f5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -82,6 +82,17 @@ You can also include an inline control phrase in a prompt: Codex settings model gpt-5.4 thinking high: inspect this repository and summarize the safest next setup check ``` +## Goal Controls + +Use `/goal` to inspect or change the selected session's Codex goal when the installed Codex app-server exposes thread goals. + +Common flow: + +1. Send `/goal` to view the current objective and status. +2. Send `/goal ` or `/goal set ` to set a new active goal. +3. Send `/goal pause`, `/goal resume`, or `/goal complete` to update goal status. +4. Send `/goal clear` when the session should no longer carry a goal. + ## Reading Output Telegram output is rate-limited and batched so busy sessions do not flood a chat. @@ -153,12 +164,15 @@ For groups and forum topics: Forum-topic flow: ```text +/launchpad on /topic new release-readiness /topic current /topic attach ``` -If `/topic new` fails, confirm the chat is a forum-enabled supergroup and the bot has the required topic-management rights. +Use `/launchpad on` when you want to launch several independent topic lanes from the chat root without leaving the workflow in launch mode forever. While armed, plain text or audio messages in the root chat create a new detached git worktree-backed topic/session pair, name the topic from the root chat title plus a lane number, and seed the new session, and each `/launch` command still creates a new detached git worktree-backed topic/session pair. The armed state expires after 10 minutes of inactivity. + +If `/topic new` fails, confirm the chat is a forum-enabled supergroup and the bot has the required topic-management rights. If `/launch` fails, confirm launchpad is armed and the chat root has the same rights. ## Shutdown And Restart diff --git a/src/Incursa.Codex.Telegram/Configuration/InteractiveBootstrapMenu.cs b/src/Incursa.Codex.Telegram/Configuration/InteractiveBootstrapMenu.cs index e6382d2..8acb600 100644 --- a/src/Incursa.Codex.Telegram/Configuration/InteractiveBootstrapMenu.cs +++ b/src/Incursa.Codex.Telegram/Configuration/InteractiveBootstrapMenu.cs @@ -184,6 +184,7 @@ private static void WriteCurrentDirectorySettingsNotice(string defaultSettingsPa Console.WriteLine("2. Send /newbot."); Console.WriteLine("3. Choose the bot display name and a username ending in bot."); Console.WriteLine("4. Copy the token BotFather returns."); + Console.WriteLine("5. The app will sync its commands, descriptions, and conservative group-admin defaults automatically when it starts."); Console.WriteLine(); while (true) @@ -208,6 +209,7 @@ private static void WriteCurrentDirectorySettingsNotice(string defaultSettingsPa store.SetTelegramEnabled(true); SaveSettings(store); Console.WriteLine($"Validated @{bot.Username ?? bot.Id.ToString(CultureInfo.InvariantCulture)} ({bot.DisplayName})."); + Console.WriteLine("The app will sync its commands, descriptions, and conservative group-admin defaults automatically on startup."); WriteBotFatherHint(bot); return token; } diff --git a/src/Incursa.Codex.Telegram/Incursa.Codex.Telegram.csproj b/src/Incursa.Codex.Telegram/Incursa.Codex.Telegram.csproj index 1c9b477..650663c 100644 --- a/src/Incursa.Codex.Telegram/Incursa.Codex.Telegram.csproj +++ b/src/Incursa.Codex.Telegram/Incursa.Codex.Telegram.csproj @@ -20,12 +20,23 @@ embedded + + true + + - + + + + + + + + diff --git a/src/Incursa.Codex.Telegram/Models/CodexDtos.cs b/src/Incursa.Codex.Telegram/Models/CodexDtos.cs index bca4d4b..9412a3e 100644 --- a/src/Incursa.Codex.Telegram/Models/CodexDtos.cs +++ b/src/Incursa.Codex.Telegram/Models/CodexDtos.cs @@ -25,6 +25,7 @@ namespace Incursa.Codex.Telegram.Models; /// Whether Codex can archive threads. /// Whether Codex can unarchive threads. /// Whether Codex can list available models. +/// Whether Codex can read and mutate thread goals. internal sealed record CodexRuntimeStateVm( bool Initialized, string? Message, @@ -45,7 +46,29 @@ internal sealed record CodexRuntimeStateVm( bool SupportsCompactThread, bool SupportsArchiveThread, bool SupportsUnarchiveThread, - bool SupportsListModels); + bool SupportsListModels, + bool SupportsThreadGoals); + +/// +/// Current Codex goal state attached to a thread. +/// +/// Codex thread ID. +/// Goal objective text. +/// Goal lifecycle status. +/// Optional token budget. +/// Tokens consumed against the goal. +/// Elapsed time consumed against the goal. +/// Goal creation time. +/// Most recent goal update time. +internal sealed record CodexThreadGoalVm( + string ThreadId, + string Objective, + CodexThreadGoalStatus Status, + long? TokenBudget, + long TokensUsed, + long TimeUsedSeconds, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); /// /// Summary row for a Codex thread shown in Telegram session lists. diff --git a/src/Incursa.Codex.Telegram/Program.cs b/src/Incursa.Codex.Telegram/Program.cs index 6d49b57..954fabf 100644 --- a/src/Incursa.Codex.Telegram/Program.cs +++ b/src/Incursa.Codex.Telegram/Program.cs @@ -229,9 +229,12 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); diff --git a/src/Incursa.Codex.Telegram/Services/CodexGateway.cs b/src/Incursa.Codex.Telegram/Services/CodexGateway.cs index af47972..7fb7752 100644 --- a/src/Incursa.Codex.Telegram/Services/CodexGateway.cs +++ b/src/Incursa.Codex.Telegram/Services/CodexGateway.cs @@ -29,6 +29,14 @@ internal interface ICodexGateway Task CompactThreadAsync(string threadId, CancellationToken cancellationToken = default); + Task GetThreadGoalAsync(string threadId, CancellationToken cancellationToken = default); + + Task SetThreadGoalAsync(string threadId, string objective, long? tokenBudget = null, CancellationToken cancellationToken = default); + + Task SetThreadGoalStatusAsync(string threadId, CodexThreadGoalStatus status, CancellationToken cancellationToken = default); + + Task ClearThreadGoalAsync(string threadId, CancellationToken cancellationToken = default); + Task> SearchWorkspaceAsync(string? query = null, CancellationToken cancellationToken = default); Task> ListModelsAsync(CancellationToken cancellationToken = default); @@ -264,6 +272,44 @@ public async Task CompactThreadAsync(string threadId, Cance return await GetThreadAsync(threadId, cancellationToken: cancellationToken).ConfigureAwait(false); } + public async Task GetThreadGoalAsync(string threadId, CancellationToken cancellationToken = default) + { + CodexRuntimeSlot runtime = await _runtimeRegistry.GetBestForThreadAsync(threadId, cancellationToken).ConfigureAwait(false); + CodexThread thread = await runtime.Client.ResumeThreadAsync(threadId, null, cancellationToken).ConfigureAwait(false); + CodexThreadGoal? goal = await thread.GetGoalAsync(cancellationToken).ConfigureAwait(false); + return goal is null ? null : CodexViewModelMapper.ToThreadGoalVm(goal); + } + + public async Task SetThreadGoalAsync( + string threadId, + string objective, + long? tokenBudget = null, + CancellationToken cancellationToken = default) + { + CodexRuntimeSlot runtime = await _runtimeRegistry.GetBestForThreadAsync(threadId, cancellationToken).ConfigureAwait(false); + CodexThread thread = await runtime.Client.ResumeThreadAsync(threadId, null, cancellationToken).ConfigureAwait(false); + CodexThreadGoal goal = await thread.SetGoalAsync(objective, tokenBudget, cancellationToken).ConfigureAwait(false); + return CodexViewModelMapper.ToThreadGoalVm(goal); + } + + public async Task SetThreadGoalStatusAsync( + string threadId, + CodexThreadGoalStatus status, + CancellationToken cancellationToken = default) + { + CodexRuntimeSlot runtime = await _runtimeRegistry.GetBestForThreadAsync(threadId, cancellationToken).ConfigureAwait(false); + CodexThread thread = await runtime.Client.ResumeThreadAsync(threadId, null, cancellationToken).ConfigureAwait(false); + CodexThreadGoal goal = await thread.SetGoalStatusAsync(status, cancellationToken).ConfigureAwait(false); + return CodexViewModelMapper.ToThreadGoalVm(goal); + } + + public async Task ClearThreadGoalAsync(string threadId, CancellationToken cancellationToken = default) + { + CodexRuntimeSlot runtime = await _runtimeRegistry.GetBestForThreadAsync(threadId, cancellationToken).ConfigureAwait(false); + CodexThread thread = await runtime.Client.ResumeThreadAsync(threadId, null, cancellationToken).ConfigureAwait(false); + return await thread.ClearGoalAsync(cancellationToken).ConfigureAwait(false); + } + public Task> SearchWorkspaceAsync(string? query = null, CancellationToken cancellationToken = default) => _workspaceBrowser.SearchAsync(query, cancellationToken); diff --git a/src/Incursa.Codex.Telegram/Services/CodexRuntimeState.cs b/src/Incursa.Codex.Telegram/Services/CodexRuntimeState.cs index 0836565..c9e21e9 100644 --- a/src/Incursa.Codex.Telegram/Services/CodexRuntimeState.cs +++ b/src/Incursa.Codex.Telegram/Services/CodexRuntimeState.cs @@ -137,7 +137,8 @@ public CodexRuntimeStateVm ToViewModel() _capabilities?.SupportsCompactThread == true, _capabilities?.SupportsArchiveThread == true, _capabilities?.SupportsUnarchiveThread == true, - _capabilities?.SupportsListModels == true); + _capabilities?.SupportsListModels == true, + _capabilities?.SupportsThreadGoals == true); } } @@ -172,6 +173,11 @@ private static void AddCapabilityWarnings(CodexRuntimeCapabilities capabilities, { warnings.Add("Model discovery is unavailable."); } + + if (!capabilities.SupportsThreadGoals) + { + warnings.Add("Thread goals are unavailable."); + } } private static string RepairText(string? value) diff --git a/src/Incursa.Codex.Telegram/Services/CodexSessionManager.cs b/src/Incursa.Codex.Telegram/Services/CodexSessionManager.cs index b83ea03..f19a507 100644 --- a/src/Incursa.Codex.Telegram/Services/CodexSessionManager.cs +++ b/src/Incursa.Codex.Telegram/Services/CodexSessionManager.cs @@ -30,6 +30,14 @@ Task UpdateModelSettingsAsync( string? reasoningEffort, CancellationToken cancellationToken); + Task GetGoalAsync(string sessionId, CancellationToken cancellationToken); + + Task SetGoalAsync(string sessionId, string objective, long? tokenBudget, CancellationToken cancellationToken); + + Task SetGoalStatusAsync(string sessionId, CodexThreadGoalStatus status, CancellationToken cancellationToken); + + Task ClearGoalAsync(string sessionId, CancellationToken cancellationToken); + Task TailAsync(string sessionId, int lineCount, CancellationToken cancellationToken); Task StopAsync(string sessionId, CancellationToken cancellationToken); @@ -276,6 +284,42 @@ public async Task UpdateModelSettingsAsync( return BuildModelSettings(session, updatedManifest, models); } + public async Task GetGoalAsync(string sessionId, CancellationToken cancellationToken) + { + await RequireSessionAsync(sessionId, cancellationToken).ConfigureAwait(false); + return await _gateway.GetThreadGoalAsync(sessionId, cancellationToken).ConfigureAwait(false); + } + + public async Task SetGoalAsync( + string sessionId, + string objective, + long? tokenBudget, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(objective)) + { + throw new ArgumentException("Goal objective cannot be empty.", nameof(objective)); + } + + await RequireSessionAsync(sessionId, cancellationToken).ConfigureAwait(false); + return await _gateway.SetThreadGoalAsync(sessionId, objective.Trim(), tokenBudget, cancellationToken).ConfigureAwait(false); + } + + public async Task SetGoalStatusAsync( + string sessionId, + CodexThreadGoalStatus status, + CancellationToken cancellationToken) + { + await RequireSessionAsync(sessionId, cancellationToken).ConfigureAwait(false); + return await _gateway.SetThreadGoalStatusAsync(sessionId, status, cancellationToken).ConfigureAwait(false); + } + + public async Task ClearGoalAsync(string sessionId, CancellationToken cancellationToken) + { + await RequireSessionAsync(sessionId, cancellationToken).ConfigureAwait(false); + return await _gateway.ClearThreadGoalAsync(sessionId, cancellationToken).ConfigureAwait(false); + } + public async Task TailAsync(string sessionId, int lineCount, CancellationToken cancellationToken) { await RequireSessionAsync(sessionId, cancellationToken).ConfigureAwait(false); diff --git a/src/Incursa.Codex.Telegram/Services/CodexViewModelMapper.cs b/src/Incursa.Codex.Telegram/Services/CodexViewModelMapper.cs index e88264b..5905cc8 100644 --- a/src/Incursa.Codex.Telegram/Services/CodexViewModelMapper.cs +++ b/src/Incursa.Codex.Telegram/Services/CodexViewModelMapper.cs @@ -161,6 +161,17 @@ public static CodexUsageVm ToUsageVm(CodexUsage usage) usage.Total.TotalTokens, usage.ModelContextWindow); + public static CodexThreadGoalVm ToThreadGoalVm(CodexThreadGoal goal) + => new( + goal.ThreadId, + RepairText(goal.Objective), + goal.Status, + goal.TokenBudget, + goal.TokensUsed, + goal.TimeUsedSeconds, + goal.CreatedAt, + goal.UpdatedAt); + public static CodexRuntimeStateVm ToRuntimeVm(CodexRuntimeState runtimeState) => runtimeState.ToViewModel(); diff --git a/src/Incursa.Codex.Telegram/Services/GitWorktreeProvisioner.cs b/src/Incursa.Codex.Telegram/Services/GitWorktreeProvisioner.cs new file mode 100644 index 0000000..6fa2fd2 --- /dev/null +++ b/src/Incursa.Codex.Telegram/Services/GitWorktreeProvisioner.cs @@ -0,0 +1,194 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using Microsoft.Extensions.Logging; +using System.Text; + +namespace Incursa.Codex.Telegram.Services; + +internal interface IGitWorktreeProvisioner +{ + Task CreateLaunchWorktreeAsync( + string sourceWorkingDirectory, + string allowlistedRoot, + string launchName, + CancellationToken cancellationToken); +} + +internal sealed record GitWorktreeProvisioningResult( + string RepositoryRoot, + string WorktreeRoot, + string WorkingDirectory); + +internal sealed class GitWorktreeProvisioner : IGitWorktreeProvisioner +{ + private readonly ILogger _logger; + + public GitWorktreeProvisioner(ILogger logger) + { + _logger = logger; + } + + public async Task CreateLaunchWorktreeAsync( + string sourceWorkingDirectory, + string allowlistedRoot, + string launchName, + CancellationToken cancellationToken) + { + string normalizedSource = Path.GetFullPath(sourceWorkingDirectory); + string repositoryRoot = await ResolveRepositoryRootAsync(normalizedSource, cancellationToken).ConfigureAwait(false); + string normalizedAllowedRoot = Path.GetFullPath(allowlistedRoot); + string repoFolderName = GetPathLeaf(repositoryRoot); + string launchSlug = BuildSlug(launchName); + string stamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + string suffix = Guid.NewGuid().ToString("n")[..8]; + string worktreeRoot = Path.Combine(normalizedAllowedRoot, ".codex-worktrees", repoFolderName, $"{launchSlug}-{stamp}-{suffix}"); + + Directory.CreateDirectory(Path.GetDirectoryName(worktreeRoot)!); + GitCommandResult worktreeResult = await RunGitAsync(repositoryRoot, cancellationToken, "worktree", "add", "--detach", worktreeRoot, "HEAD").ConfigureAwait(false); + if (worktreeResult.ExitCode != 0) + { + throw new InvalidOperationException( + $"git worktree add failed while provisioning '{worktreeRoot}' from '{repositoryRoot}'.{Environment.NewLine}{worktreeResult.GetCombinedOutput()}"); + } + + string relativeSource = Path.GetRelativePath(repositoryRoot, normalizedSource); + string workingDirectory = string.Equals(relativeSource, ".", StringComparison.Ordinal) + ? worktreeRoot + : Path.GetFullPath(Path.Combine(worktreeRoot, relativeSource)); + + _logger.LogInformation( + "Provisioned git worktree {WorktreeRoot} for source {SourceWorkingDirectory} at repository root {RepositoryRoot}.", + worktreeRoot, + normalizedSource, + repositoryRoot); + + return new GitWorktreeProvisioningResult(repositoryRoot, worktreeRoot, workingDirectory); + } + + private async Task ResolveRepositoryRootAsync(string sourceWorkingDirectory, CancellationToken cancellationToken) + { + GitCommandResult result = await RunGitAsync(sourceWorkingDirectory, cancellationToken, "rev-parse", "--show-toplevel").ConfigureAwait(false); + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"The working directory '{sourceWorkingDirectory}' is not inside a git repository.{Environment.NewLine}{result.GetCombinedOutput()}"); + } + + string repositoryRoot = result.StandardOutput.Trim(); + if (string.IsNullOrWhiteSpace(repositoryRoot)) + { + throw new InvalidOperationException($"git did not return a repository root for '{sourceWorkingDirectory}'."); + } + + return Path.GetFullPath(repositoryRoot); + } + + private async Task RunGitAsync( + string workingDirectory, + CancellationToken cancellationToken, + params string[] arguments) + { + ProcessStartInfo startInfo = new() + { + FileName = "git", + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + try + { + using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start git."); + Task stdoutTask = process.StandardOutput.ReadToEndAsync(); + Task stderrTask = process.StandardError.ReadToEndAsync(); + using CancellationTokenRegistration registration = cancellationToken.Register(() => + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + } + }); + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + return new GitCommandResult( + process.ExitCode, + await stdoutTask.ConfigureAwait(false), + await stderrTask.ConfigureAwait(false)); + } + catch (Win32Exception exception) + { + throw new InvalidOperationException("git is unavailable or could not be started.", exception); + } + } + + private static string BuildSlug(string value) + { + StringBuilder builder = new(value.Length); + bool appendedDash = false; + foreach (char ch in value.Trim()) + { + if (char.IsLetterOrDigit(ch)) + { + builder.Append(char.ToLowerInvariant(ch)); + appendedDash = false; + continue; + } + + if (builder.Length == 0 || appendedDash) + { + continue; + } + + builder.Append('-'); + appendedDash = true; + } + + string slug = builder.ToString().Trim('-'); + if (string.IsNullOrWhiteSpace(slug)) + { + slug = "lane"; + } + + return slug.Length <= 48 ? slug : slug[..48].Trim('-'); + } + + private static string GetPathLeaf(string path) + { + string trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string leaf = Path.GetFileName(trimmed); + return string.IsNullOrWhiteSpace(leaf) ? "repo" : leaf; + } + + private sealed record GitCommandResult(int ExitCode, string StandardOutput, string StandardError) + { + public string GetCombinedOutput() + { + StringBuilder builder = new(); + if (!string.IsNullOrWhiteSpace(StandardError)) + { + builder.AppendLine(StandardError.Trim()); + } + + if (!string.IsNullOrWhiteSpace(StandardOutput)) + { + builder.AppendLine(StandardOutput.Trim()); + } + + return builder.ToString().TrimEnd(); + } + } +} diff --git a/src/Incursa.Codex.Telegram/Telegram/TelegramBotMetadata.cs b/src/Incursa.Codex.Telegram/Telegram/TelegramBotMetadata.cs new file mode 100644 index 0000000..ce62b5f --- /dev/null +++ b/src/Incursa.Codex.Telegram/Telegram/TelegramBotMetadata.cs @@ -0,0 +1,89 @@ +using Telegram.Bot.Types; + +namespace Incursa.Codex.Telegram.Telegram; + +internal sealed record TelegramBotCommandDefinition(string Name, string Description); + +internal static class TelegramBotMetadata +{ + internal static readonly IReadOnlyList PublicCommands = + [ + new("help", "show this help"), + new("whoami", "show Telegram user, chat, and topic thread IDs"), + new("version", "show the running Codex Telegram app version"), + new("trust", "trust the current group or forum chat for allowlisted users"), + new("projects", "list known project directories"), + new("project", "add or select a project"), + new("topics", "list Telegram topics/sessions in this chat"), + new("topic", "manage forum-topic sessions"), + new("launchpad", "arm or disarm root-chat launch mode for plain-text and audio launches"), + new("launch", "create a detached git worktree-backed forum topic and session while launchpad is armed"), + new("sessions", "show active and Telegram-managed sessions"), + new("new", "create and select a Codex session in the active project"), + new("use", "select the active session for this conversation"), + new("send", "send text to the active session"), + new("steer", "steer the active turn in the selected session"), + new("queue", "view, edit, delete, or send queued prompts now"), + new("model", "show or change the selected session model"), + new("thinking", "change the selected session thinking effort"), + new("goal", "show or change the selected session goal"), + new("tail", "show recent output and keep following the session live"), + new("status", "show session status"), + new("usage", "show Codex account usage remaining and reset times"), + new("doctor", "explain authorization, routing, active project/session, workspace roots, and queue state"), + new("outbound", "show outbound Telegram queue status"), + new("stop", "gracefully stop a session"), + new("restart", "show restart guidance"), + ]; + + internal static string Description + => "Controls a local Codex installation from Telegram and routes prompts to sessions."; + + internal static string ShortDescription + => "Control local Codex sessions from Telegram."; + + internal static ChatAdministratorRights DefaultGroupAdministratorRights + => new() + { + CanManageTopics = true, + }; + + internal static IReadOnlyList BuildPublicBotCommands() + => PublicCommands.Select(command => new global::Telegram.Bot.Types.BotCommand(command.Name, command.Description)).ToArray(); + + internal static IReadOnlyList BuildHelpLines() + => [ + "Commands:", + "Use the buttons below for quick navigation between sessions, projects, and help.", + ..PublicCommands.Select(command => $"/{command.Name} - {command.Description}"), + "/project add - add and select a project", + "/project - select a project", + "/project current - show the active project for the current conversation", + "/topic list - list Telegram topics/sessions in this chat", + "/topic new [| ] - create a new Telegram forum topic and session in a forum-enabled supergroup", + "/topic attach [sessionId] - bind the current Telegram forum topic to an existing Codex session", + "/topic current - show the active topic/session in this conversation", + "/launchpad on|off|status - arm or disarm root-chat launch mode for repeated plain-text and audio launches", + "/launch [| ] - create a detached git worktree-backed forum topic and session while launchpad is armed", + "/sessions all [count] - show recent Codex history", + "/model [model] [thinking ] - show or change the selected session model", + "/thinking - change the selected session thinking effort", + "/goal [objective|set |clear|pause|resume|complete] - show or change the selected session goal", + "/tail [count] - show recent output and keep following the session live", + "/status [sessionId] - show session status", + "/queue - view, edit, delete, or send queued prompts now", + "/usage - show Codex account usage remaining and reset times", + "/doctor - explain authorization, routing, active project/session, workspace roots, and queue state", + "/outbound - show outbound Telegram queue status", + "/stop [sessionId] - gracefully stop a session", + "/restart confirm - explain how to restart this standalone process", + "/kill confirm - hard-stop a session", + "/rename - rename a session", + "/forget - hide a stopped/exited session without deleting logs", + "Plain text and audio in a private chat, trusted group, or topic stay on that conversation's session; if the conversation has none yet, the first message starts one and live output follows automatically.", + "In forum topics, if plain text gets no response, Telegram bot privacy is likely hiding non-command messages; use /send or disable privacy for this bot.", + "Launchpad mode arms a root supergroup for repeated plain-text or audio launch messages that create detached git worktree-backed forum-topic sessions and seed the new session. It expires after 10 minutes of inactivity.", + "Images, documents, and other attachments are forwarded to Codex; voice notes are transcribed with the configured OpenAI transcription model first.", + "Voice/text control phrase: Codex settings model gpt-5.4-mini thinking high: ", + ]; +} diff --git a/src/Incursa.Codex.Telegram/Telegram/TelegramBotMetadataSyncService.cs b/src/Incursa.Codex.Telegram/Telegram/TelegramBotMetadataSyncService.cs new file mode 100644 index 0000000..97efbff --- /dev/null +++ b/src/Incursa.Codex.Telegram/Telegram/TelegramBotMetadataSyncService.cs @@ -0,0 +1,164 @@ +using Incursa.Codex.Telegram.Options; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Telegram.Bot; +using Telegram.Bot.Exceptions; +using Telegram.Bot.Types; + +namespace Incursa.Codex.Telegram.Telegram; + +internal sealed class TelegramBotMetadataSyncService : IHostedService +{ + private readonly TelegramBotOptions _options; + private readonly ILogger _logger; + private readonly Lazy _client; + + public TelegramBotMetadataSyncService( + IOptions options, + ILogger logger) + : this( + options.Value, + logger, + new Lazy(() => new TelegramBotProfileApiClient(new TelegramBotClient(RequireToken(options.Value))))) + { + } + + internal TelegramBotMetadataSyncService( + TelegramBotOptions options, + ILogger logger, + ITelegramBotProfileApiClient client) + : this(options, logger, new Lazy(() => client)) + { + } + + private TelegramBotMetadataSyncService( + TelegramBotOptions options, + ILogger logger, + Lazy client) + { + _options = options; + _logger = logger; + _client = client; + } + + public Task StartAsync(CancellationToken cancellationToken) + => SyncAsync(cancellationToken); + + public Task StopAsync(CancellationToken cancellationToken) + => Task.CompletedTask; + + public async Task SyncAsync(CancellationToken cancellationToken) + { + if (!_options.Enabled) + { + _logger.LogDebug("Telegram bot metadata sync skipped because Telegram is disabled."); + return; + } + + if (string.IsNullOrWhiteSpace(_options.Token)) + { + _logger.LogWarning("Telegram bot metadata sync skipped because TelegramBot:Token is missing."); + return; + } + + _logger.LogInformation("Synchronizing Telegram bot metadata and default group settings."); + + int appliedCount = 0; + appliedCount += await TryApplyAsync( + "commands", + cancellationToken => _client.Value.SetMyCommandsAsync(TelegramBotMetadata.BuildPublicBotCommands(), cancellationToken), + cancellationToken).ConfigureAwait(false) ? 1 : 0; + appliedCount += await TryApplyAsync( + "description", + cancellationToken => _client.Value.SetMyDescriptionAsync(TelegramBotMetadata.Description, cancellationToken), + cancellationToken).ConfigureAwait(false) ? 1 : 0; + appliedCount += await TryApplyAsync( + "short description", + cancellationToken => _client.Value.SetMyShortDescriptionAsync(TelegramBotMetadata.ShortDescription, cancellationToken), + cancellationToken).ConfigureAwait(false) ? 1 : 0; + appliedCount += await TryApplyAsync( + "default group admin rights", + cancellationToken => _client.Value.SetMyDefaultAdministratorRightsAsync(TelegramBotMetadata.DefaultGroupAdministratorRights, cancellationToken), + cancellationToken).ConfigureAwait(false) ? 1 : 0; + + _logger.LogInformation( + "Telegram bot metadata sync completed; {AppliedCount} of {ExpectedCount} settings were applied or already current.", + appliedCount, + 4); + } + + private async Task TryApplyAsync( + string label, + Func action, + CancellationToken cancellationToken) + { + try + { + await action(cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Telegram bot {Label} updated.", label); + return true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (ApiRequestException exception) when (label == "default group admin rights" && IsRightsNotModified(exception)) + { + _logger.LogDebug("Telegram bot default group admin rights already match the desired defaults."); + return true; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Telegram bot {Label} sync failed; continuing startup.", label); + return false; + } + } + + private static bool IsRightsNotModified(ApiRequestException exception) + => exception.Message.Contains("rights not modified", StringComparison.OrdinalIgnoreCase) + || exception.Message.Contains("not modified", StringComparison.OrdinalIgnoreCase); + + private static string RequireToken(TelegramBotOptions options) + { + if (string.IsNullOrWhiteSpace(options.Token)) + { + throw new InvalidOperationException("TelegramBot:Token must be configured when TelegramBot:Enabled is true."); + } + + return options.Token.Trim(); + } +} + +internal interface ITelegramBotProfileApiClient +{ + Task SetMyCommandsAsync(IEnumerable commands, CancellationToken cancellationToken); + + Task SetMyDescriptionAsync(string description, CancellationToken cancellationToken); + + Task SetMyShortDescriptionAsync(string shortDescription, CancellationToken cancellationToken); + + Task SetMyDefaultAdministratorRightsAsync(ChatAdministratorRights rights, CancellationToken cancellationToken); +} + +internal sealed class TelegramBotProfileApiClient : ITelegramBotProfileApiClient +{ + private readonly ITelegramBotClient _client; + + public TelegramBotProfileApiClient(ITelegramBotClient client) + { + _client = client; + } + + public Task SetMyCommandsAsync(IEnumerable commands, CancellationToken cancellationToken) + => _client.SetMyCommands(commands, BotCommandScope.Default(), string.Empty, cancellationToken); + + public Task SetMyDescriptionAsync(string description, CancellationToken cancellationToken) + => _client.SetMyDescription(description, string.Empty, cancellationToken); + + public Task SetMyShortDescriptionAsync(string shortDescription, CancellationToken cancellationToken) + => _client.SetMyShortDescription(shortDescription, string.Empty, cancellationToken); + + public Task SetMyDefaultAdministratorRightsAsync(ChatAdministratorRights rights, CancellationToken cancellationToken) + => _client.SetMyDefaultAdministratorRights(rights, false, cancellationToken); +} diff --git a/src/Incursa.Codex.Telegram/Telegram/TelegramBotStateStore.cs b/src/Incursa.Codex.Telegram/Telegram/TelegramBotStateStore.cs index 06ef4bd..dd0ce8b 100644 --- a/src/Incursa.Codex.Telegram/Telegram/TelegramBotStateStore.cs +++ b/src/Incursa.Codex.Telegram/Telegram/TelegramBotStateStore.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using Incursa.Codex.Telegram.Options; @@ -53,6 +54,14 @@ internal interface ITelegramBotStateStore Task> ListConversationStatesForChatAsync(long chatId, CancellationToken cancellationToken); + Task GetLaunchpadStateAsync(TelegramConversationScope conversation, CancellationToken cancellationToken); + + Task SetLaunchpadStateAsync(TelegramConversationScope conversation, DateTimeOffset lastTouchedUtc, CancellationToken cancellationToken); + + Task ClearLaunchpadStateAsync(TelegramConversationScope conversation, CancellationToken cancellationToken); + + Task ReserveLaunchpadTopicSequenceAsync(long chatId, CancellationToken cancellationToken); + Task ClearActiveSessionForSessionAsync(string sessionId, CancellationToken cancellationToken); Task TrackSessionAsync(string sessionId, CancellationToken cancellationToken); @@ -95,7 +104,10 @@ internal sealed record TelegramConversationState( string? ActiveSessionId, string? ActiveProjectWorkingDirectory, int QueuedPromptCount, - DateTimeOffset? OldestQueuedPromptAt); + DateTimeOffset? OldestQueuedPromptAt, + DateTimeOffset? LaunchpadLastTouchedUtc); + +internal sealed record TelegramLaunchpadState(DateTimeOffset LastTouchedUtc); internal sealed class TelegramBotStateStore : ITelegramBotStateStore { @@ -212,6 +224,61 @@ public async Task> ListConversati return BuildConversationStates(state, chatId); } + public async Task GetLaunchpadStateAsync(TelegramConversationScope conversation, CancellationToken cancellationToken) + { + TelegramBotState state = await LoadStateAsync(cancellationToken).ConfigureAwait(false); + return state.LaunchpadStatesByScope.TryGetValue(conversation.ToStorageKey(), out TelegramLaunchpadState? launchpadState) + ? launchpadState + : null; + } + + public Task SetLaunchpadStateAsync(TelegramConversationScope conversation, DateTimeOffset lastTouchedUtc, CancellationToken cancellationToken) + => MutateAsync(state => + { + state.LaunchpadStatesByScope[conversation.ToStorageKey()] = new TelegramLaunchpadState(lastTouchedUtc.ToUniversalTime()); + return state; + }, cancellationToken); + + public async Task ReserveLaunchpadTopicSequenceAsync(long chatId, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + TelegramBotState state = await LoadStateCoreAsync(cancellationToken).ConfigureAwait(false); + string key = chatId.ToString(CultureInfo.InvariantCulture); + int sequence = state.LaunchpadTopicSequencesByChatId.TryGetValue(key, out int current) + ? current + 1 + : 1; + state.LaunchpadTopicSequencesByChatId[key] = sequence; + await SaveStateCoreAsync(state, cancellationToken).ConfigureAwait(false); + return sequence; + } + finally + { + _gate.Release(); + } + } + + public async Task ClearLaunchpadStateAsync(TelegramConversationScope conversation, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + TelegramBotState state = await LoadStateCoreAsync(cancellationToken).ConfigureAwait(false); + bool removed = state.LaunchpadStatesByScope.Remove(conversation.ToStorageKey()); + if (removed) + { + await SaveStateCoreAsync(state, cancellationToken).ConfigureAwait(false); + } + + return removed; + } + finally + { + _gate.Release(); + } + } + public Task ClearActiveSessionForSessionAsync(string sessionId, CancellationToken cancellationToken) => MutateAsync(state => { @@ -471,6 +538,14 @@ private IReadOnlyCollection BuildConversationStates(T } } + foreach (KeyValuePair pair in state.LaunchpadStatesByScope) + { + if (TryParseScopeForChat(pair.Key, chatId, out TelegramConversationScope scope)) + { + GetBuilder(scope).LaunchpadLastTouchedUtc = pair.Value.LastTouchedUtc; + } + } + IEnumerable queuedPrompts = chatId.HasValue ? state.QueuedPrompts.Where(prompt => prompt.ChatId == chatId.Value) : state.QueuedPrompts; @@ -553,8 +628,10 @@ private sealed class ConversationStateBuilder(TelegramConversationScope scope) public DateTimeOffset? OldestQueuedPromptAt { get; set; } + public DateTimeOffset? LaunchpadLastTouchedUtc { get; set; } + public TelegramConversationState ToState() - => new(Scope, ActiveSessionId, ActiveProjectWorkingDirectory, QueuedPromptCount, OldestQueuedPromptAt); + => new(Scope, ActiveSessionId, ActiveProjectWorkingDirectory, QueuedPromptCount, OldestQueuedPromptAt, LaunchpadLastTouchedUtc); } private sealed class TelegramBotState @@ -572,5 +649,9 @@ private sealed class TelegramBotState public List TrustedChatIds { get; set; } = []; public List QueuedPrompts { get; set; } = []; + + public Dictionary LaunchpadStatesByScope { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary LaunchpadTopicSequencesByChatId { get; set; } = new(StringComparer.OrdinalIgnoreCase); } } diff --git a/src/Incursa.Codex.Telegram/Telegram/TelegramCodexBotCommandHandler.cs b/src/Incursa.Codex.Telegram/Telegram/TelegramCodexBotCommandHandler.cs index 1d47f4b..49e1e8e 100644 --- a/src/Incursa.Codex.Telegram/Telegram/TelegramCodexBotCommandHandler.cs +++ b/src/Incursa.Codex.Telegram/Telegram/TelegramCodexBotCommandHandler.cs @@ -49,7 +49,8 @@ internal sealed record TelegramInboundMessage( int? MessageThreadId = null, string? AudioFilePath = null, IReadOnlyList? Attachments = null, - int? SourceMessageId = null) + int? SourceMessageId = null, + string? ChatTitle = null) { public TelegramConversationScope ConversationScope => new(ChatId, MessageThreadId); } @@ -98,6 +99,7 @@ internal sealed class TelegramCodexBotCommandHandler : ITelegramCodexBotUpdateHa private readonly ITelegramForumTopicService _topicService; private readonly IAudioTranscriptionService _audioTranscriptionService; private readonly IOutboundTelegramQueue _outboundQueue; + private readonly IGitWorktreeProvisioner _worktreeProvisioner; private readonly TelegramBotOptions _options; private readonly ILogger _logger; private readonly SemaphoreSlim _usageSummaryLock = new(1, 1); @@ -118,6 +120,7 @@ public TelegramCodexBotCommandHandler( ITelegramForumTopicService topicService, IAudioTranscriptionService audioTranscriptionService, IOutboundTelegramQueue outboundQueue, + IGitWorktreeProvisioner worktreeProvisioner, IOptions options, ILogger logger) { @@ -133,6 +136,7 @@ public TelegramCodexBotCommandHandler( _topicService = topicService; _audioTranscriptionService = audioTranscriptionService; _outboundQueue = outboundQueue; + _worktreeProvisioner = worktreeProvisioner; _options = options.Value; _logger = logger; } @@ -191,6 +195,11 @@ await ReplyAsync( if (!command.IsCommand) { + if (await TryHandleLaunchpadMessageAsync(message, command.Text, sender, cancellationToken).ConfigureAwait(false)) + { + return; + } + _logger.LogDebug( "Routing plain Telegram message for chat {ChatId} topic {MessageThreadId}; text length {TextLength}; attachments {AttachmentCount}.", message.ChatId, @@ -233,6 +242,12 @@ await ReplyAsync( case "threads": await HandleTopicListAsync(message, sender, cancellationToken).ConfigureAwait(false); break; + case "launchpad": + await HandleLaunchpadAsync(message, command.Arguments, sender, cancellationToken).ConfigureAwait(false); + break; + case "launch": + await HandleLaunchAsync(message, command.Arguments, sender, cancellationToken).ConfigureAwait(false); + break; case "sessions": await HandleSessionsAsync(message, sender, cancellationToken).ConfigureAwait(false); break; @@ -258,6 +273,9 @@ await ReplyAsync( case "thinking": await HandleThinkingAsync(message, command.Arguments, sender, cancellationToken).ConfigureAwait(false); break; + case "goal": + await HandleGoalAsync(message, command.Arguments, sender, cancellationToken).ConfigureAwait(false); + break; case "tail": await HandleTailAsync(message, command.Arguments, sender, cancellationToken).ConfigureAwait(false); break; @@ -664,11 +682,185 @@ private async Task HandleTopicNewAsync( await CreateTopicAndSessionAsync( message, request.Name, + null, workingDirectory, sender, cancellationToken).ConfigureAwait(false); } + private async Task HandleLaunchpadAsync(TelegramInboundMessage message, string arguments, ITelegramBotMessageSender sender, CancellationToken cancellationToken) + { + if (!IsLaunchpadRootChat(message)) + { + await ReplyAsync(sender, message, BuildLaunchpadUnsupportedMessage(message), null, cancellationToken).ConfigureAwait(false); + return; + } + + string[] parts = SplitArguments(arguments, 2); + string action = parts.Length == 0 ? "status" : parts[0]; + switch (action) + { + case "": + case "status": + await HandleLaunchpadStatusAsync(message, sender, cancellationToken).ConfigureAwait(false); + break; + case "on": + case "arm": + case "enable": + await HandleLaunchpadOnAsync(message, sender, cancellationToken).ConfigureAwait(false); + break; + case "off": + case "disarm": + case "disable": + await HandleLaunchpadOffAsync(message, sender, cancellationToken).ConfigureAwait(false); + break; + default: + await ReplyAsync( + sender, + message, + "Usage: /launchpad on | off | status", + null, + cancellationToken).ConfigureAwait(false); + break; + } + } + + private async Task HandleLaunchAsync(TelegramInboundMessage message, string arguments, ITelegramBotMessageSender sender, CancellationToken cancellationToken) + { + if (!IsLaunchpadRootChat(message)) + { + await ReplyAsync(sender, message, BuildLaunchpadUnsupportedMessage(message), null, cancellationToken).ConfigureAwait(false); + return; + } + + (TelegramLaunchpadState? launchpadState, bool expired) = await ResolveLaunchpadStateAsync(message.ConversationScope, clearExpired: true, cancellationToken).ConfigureAwait(false); + if (launchpadState is null) + { + await ReplyAsync( + sender, + message, + expired + ? $"Launchpad expired after {TelegramLaunchpadPolicy.InactivityTimeout.TotalMinutes.ToString(CultureInfo.InvariantCulture)} minutes of inactivity. Use /launchpad on to arm it again." + : "Launchpad is not armed yet. Use /launchpad on before /launch.", + null, + cancellationToken).ConfigureAwait(false); + return; + } + + await LaunchTopicAsync(message, arguments, sender, cancellationToken).ConfigureAwait(false); + } + + private async Task TryHandleLaunchpadMessageAsync( + TelegramInboundMessage message, + string text, + ITelegramBotMessageSender sender, + CancellationToken cancellationToken) + { + if (!IsLaunchpadRootChat(message) || string.IsNullOrWhiteSpace(text)) + { + return false; + } + + (TelegramLaunchpadState? launchpadState, _) = await ResolveLaunchpadStateAsync(message.ConversationScope, clearExpired: true, cancellationToken).ConfigureAwait(false); + if (launchpadState is null) + { + return false; + } + + await LaunchTopicAsync(message, text, sender, cancellationToken, useLaunchpadTopicNaming: true).ConfigureAwait(false); + return true; + } + + private async Task LaunchTopicAsync( + TelegramInboundMessage message, + string arguments, + ITelegramBotMessageSender sender, + CancellationToken cancellationToken, + bool useLaunchpadTopicNaming = false) + { + TopicCreationRequest request = ParseTopicCreationRequest(arguments); + if (!request.IsValid) + { + await ReplyAsync(sender, message, "Usage: /launch [| ]", null, cancellationToken).ConfigureAwait(false); + return; + } + + string sourceWorkingDirectory; + string allowlistedRoot; + if (!string.IsNullOrWhiteSpace(request.WorkingDirectory)) + { + CodexWorkspaceValidationVm validation = _workspaceBrowser.ValidateWorkingDirectory(request.WorkingDirectory); + if (!validation.IsValid || string.IsNullOrWhiteSpace(validation.NormalizedPath)) + { + await ReplyAsync(sender, message, $"Project path rejected: {validation.Message}", null, cancellationToken).ConfigureAwait(false); + return; + } + + sourceWorkingDirectory = validation.NormalizedPath; + allowlistedRoot = validation.AllowlistedRoot ?? validation.NormalizedPath; + } + else + { + ResolvedProject resolvedProject = await ResolveActiveProjectAsync(message.ConversationScope, cancellationToken).ConfigureAwait(false); + if (resolvedProject.Project is null) + { + await ReplyAsync(sender, message, resolvedProject.Message, null, cancellationToken).ConfigureAwait(false); + return; + } + + CodexWorkspaceValidationVm validation = _workspaceBrowser.ValidateWorkingDirectory(resolvedProject.Project.WorkingDirectory); + if (!validation.IsValid || string.IsNullOrWhiteSpace(validation.NormalizedPath)) + { + await ReplyAsync(sender, message, $"Project path rejected: {validation.Message}", null, cancellationToken).ConfigureAwait(false); + return; + } + + sourceWorkingDirectory = validation.NormalizedPath; + allowlistedRoot = validation.AllowlistedRoot ?? validation.NormalizedPath; + } + + string launchPromptText = request.Name.Trim(); + string topicName; + string sessionName; + if (useLaunchpadTopicNaming) + { + int launchSequence = await _stateStore.ReserveLaunchpadTopicSequenceAsync(message.ChatId, cancellationToken).ConfigureAwait(false); + topicName = BuildLaunchpadTopicName(message.ChatTitle, launchSequence); + sessionName = topicName; + } + else + { + topicName = BuildLaunchNameFromText(launchPromptText); + sessionName = topicName; + } + + GitWorktreeProvisioningResult worktree; + try + { + worktree = await _worktreeProvisioner.CreateLaunchWorktreeAsync(sourceWorkingDirectory, allowlistedRoot, topicName, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Launchpad worktree provisioning failed for source directory {SourceWorkingDirectory}.", sourceWorkingDirectory); + await ReplyAsync(sender, message, $"Worktree provisioning failed: {exception.Message}", null, cancellationToken).ConfigureAwait(false); + return; + } + + await _projectCatalogStore.AddAsync(worktree.WorkingDirectory, cancellationToken).ConfigureAwait(false); + await _stateStore.SetLaunchpadStateAsync(message.ConversationScope, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + await CreateTopicAndSessionAsync( + message, + topicName, + sessionName, + worktree.WorkingDirectory, + sender, + cancellationToken, + templateConversation: message.ConversationScope, + rootReplyVerb: "Launched", + topicReplyVerb: "Launched and selected", + seedPromptText: launchPromptText).ConfigureAwait(false); + } + private async Task HandleTopicCurrentAsync(TelegramInboundMessage message, ITelegramBotMessageSender sender, CancellationToken cancellationToken) { ResolvedSession session = await ResolveActiveSessionAsync(message.ConversationScope, cancellationToken).ConfigureAwait(false); @@ -766,7 +958,7 @@ private async Task HandleTopicNewFromMenuAsync(TelegramInboundMessage message, I } string topicName = BuildDefaultTopicName(resolvedProject.Project); - await CreateTopicAndSessionAsync(message, topicName, resolvedProject.Project.WorkingDirectory, sender, cancellationToken).ConfigureAwait(false); + await CreateTopicAndSessionAsync(message, topicName, null, resolvedProject.Project.WorkingDirectory, sender, cancellationToken).ConfigureAwait(false); } private async Task HandleNewAsync(TelegramInboundMessage message, string arguments, ITelegramBotMessageSender sender, CancellationToken cancellationToken) @@ -791,6 +983,96 @@ private async Task HandleNewAsync(TelegramInboundMessage message, string argumen await ReplyAsync(sender, message, BuildSelectedSessionReply("Created and selected", session, settings, usageSummary: usageSummary), BuildSessionButtons([session], includeUse: false), cancellationToken).ConfigureAwait(false); } + private async Task HandleLaunchpadStatusAsync(TelegramInboundMessage message, ITelegramBotMessageSender sender, CancellationToken cancellationToken) + { + await ReplyAsync(sender, message, await BuildLaunchpadStatusTextAsync(message, cancellationToken).ConfigureAwait(false), null, cancellationToken).ConfigureAwait(false); + } + + private async Task HandleLaunchpadOnAsync(TelegramInboundMessage message, ITelegramBotMessageSender sender, CancellationToken cancellationToken) + { + await _stateStore.SetLaunchpadStateAsync(message.ConversationScope, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + await ReplyAsync(sender, message, await BuildLaunchpadStatusTextAsync(message, cancellationToken).ConfigureAwait(false), null, cancellationToken).ConfigureAwait(false); + } + + private async Task HandleLaunchpadOffAsync(TelegramInboundMessage message, ITelegramBotMessageSender sender, CancellationToken cancellationToken) + { + bool removed = await _stateStore.ClearLaunchpadStateAsync(message.ConversationScope, cancellationToken).ConfigureAwait(false); + await ReplyAsync( + sender, + message, + removed + ? $"Launchpad disarmed. Use /launchpad on to arm this root chat for {TelegramLaunchpadPolicy.InactivityTimeout.TotalMinutes.ToString(CultureInfo.InvariantCulture)} minutes." + : "Launchpad is already off. Use /launchpad on to arm this root chat.", + null, + cancellationToken).ConfigureAwait(false); + } + + private async Task BuildLaunchpadStatusTextAsync(TelegramInboundMessage message, CancellationToken cancellationToken) + { + (TelegramLaunchpadState? state, bool expired) = await ResolveLaunchpadStateAsync(message.ConversationScope, clearExpired: true, cancellationToken).ConfigureAwait(false); + StringBuilder builder = new(); + if (state is null) + { + builder.AppendLine(expired + ? $"Launchpad expired after {TelegramLaunchpadPolicy.InactivityTimeout.TotalMinutes.ToString(CultureInfo.InvariantCulture)} minutes of inactivity and is now off." + : "Launchpad is off."); + builder.AppendLine($"Use /launchpad on to arm this root chat for {TelegramLaunchpadPolicy.InactivityTimeout.TotalMinutes.ToString(CultureInfo.InvariantCulture)} minutes."); + return builder.ToString().TrimEnd(); + } + + builder.AppendLine("Launchpad is armed for this root chat."); + builder.AppendLine("Plain-text and audio messages will create detached git worktree-backed topic/session lanes named from the root chat title plus a lane number, then seed the new session."); + builder.AppendLine($"Expires in {FormatLaunchpadCountdown(state.LastTouchedUtc)} if unused."); + builder.AppendLine(await BuildLaunchpadTemplateTextAsync(message.ConversationScope, cancellationToken).ConfigureAwait(false)); + ResolvedProject activeProject = await ResolveActiveProjectAsync(message.ConversationScope, cancellationToken).ConfigureAwait(false); + builder.AppendLine(activeProject.Project is null + ? "Active project: " + : $"Active project: {activeProject.Project.Name} ({activeProject.Project.WorkingDirectory})"); + if (activeProject.Project is null && !string.IsNullOrWhiteSpace(activeProject.Message)) + { + builder.AppendLine(activeProject.Message); + } + builder.AppendLine("Use /launch [| ] to create a new forum topic and session."); + builder.AppendLine("Use /launchpad off to disarm."); + return builder.ToString().TrimEnd(); + } + + private async Task BuildLaunchpadTemplateTextAsync(TelegramConversationScope conversation, CancellationToken cancellationToken) + { + ResolvedSession activeSession = await ResolveActiveSessionAsync(conversation, cancellationToken).ConfigureAwait(false); + if (activeSession.Session is null) + { + return "Launch template: Codex defaults."; + } + + CodexSessionModelSettings settings = await _sessionManager.GetModelSettingsAsync(activeSession.Session.Id, cancellationToken).ConfigureAwait(false); + return $"Launch template: current root session {activeSession.Session.Name} ({FormatModelDisplay(settings)}, {FormatValue(settings.ReasoningEffort)})."; + } + + private async Task<(TelegramLaunchpadState? State, bool Expired)> ResolveLaunchpadStateAsync( + TelegramConversationScope conversation, + bool clearExpired, + CancellationToken cancellationToken) + { + TelegramLaunchpadState? state = await _stateStore.GetLaunchpadStateAsync(conversation, cancellationToken).ConfigureAwait(false); + if (state is null) + { + return (null, false); + } + + if (!TelegramLaunchpadPolicy.IsExpired(state.LastTouchedUtc, DateTimeOffset.UtcNow)) + { + return (state, false); + } + + if (clearExpired) + { + await _stateStore.ClearLaunchpadStateAsync(conversation, cancellationToken).ConfigureAwait(false); + } + + return (null, true); + } + private async Task HandleUseAsync(TelegramInboundMessage message, string arguments, ITelegramBotMessageSender sender, CancellationToken cancellationToken) { ResolvedSession resolved = await ResolveSessionAsync(message.ConversationScope, arguments, cancellationToken).ConfigureAwait(false); @@ -898,6 +1180,11 @@ private async Task HandleAudioMessageAsync(TelegramInboundMessage message, ITele } await ReplyAsync(sender, message, $"Here's what I transcribed:{Environment.NewLine}{Environment.NewLine}{transcript}", null, cancellationToken).ConfigureAwait(false); + if (await TryHandleLaunchpadMessageAsync(message, transcript, sender, cancellationToken).ConfigureAwait(false)) + { + return; + } + await SendToActiveSessionAsync(message with { Text = transcript, AudioFilePath = null, Attachments = null }, transcript, sender, cancellationToken).ConfigureAwait(false); } @@ -1247,6 +1534,73 @@ private async Task HandleThinkingAsync(TelegramInboundMessage message, string ar await HandleModelAsync(message, "thinking " + arguments, sender, cancellationToken).ConfigureAwait(false); } + private async Task HandleGoalAsync(TelegramInboundMessage message, string arguments, ITelegramBotMessageSender sender, CancellationToken cancellationToken) + { + ResolvedSession resolved = await ResolveActiveSessionAsync(message.ConversationScope, cancellationToken).ConfigureAwait(false); + if (resolved.Session is null) + { + await ReplyAsync(sender, message, resolved.Message, null, cancellationToken).ConfigureAwait(false); + return; + } + + GoalCommandRequest request = ParseGoalCommand(arguments); + if (request.Action is GoalCommandAction.Invalid) + { + await ReplyAsync(sender, message, "Usage: /goal [objective|set |clear|pause|resume|complete]", null, cancellationToken).ConfigureAwait(false); + return; + } + + try + { + CodexThreadGoalVm? goal = request.Action switch + { + GoalCommandAction.Show => await _sessionManager.GetGoalAsync(resolved.Session.Id, cancellationToken).ConfigureAwait(false), + GoalCommandAction.Set => await _sessionManager.SetGoalAsync(resolved.Session.Id, request.Objective ?? string.Empty, request.TokenBudget, cancellationToken).ConfigureAwait(false), + GoalCommandAction.Pause => await _sessionManager.SetGoalStatusAsync(resolved.Session.Id, CodexThreadGoalStatus.Paused, cancellationToken).ConfigureAwait(false), + GoalCommandAction.Resume => await _sessionManager.SetGoalStatusAsync(resolved.Session.Id, CodexThreadGoalStatus.Active, cancellationToken).ConfigureAwait(false), + GoalCommandAction.Complete => await _sessionManager.SetGoalStatusAsync(resolved.Session.Id, CodexThreadGoalStatus.Complete, cancellationToken).ConfigureAwait(false), + GoalCommandAction.Clear => null, + _ => null, + }; + + if (request.Action is GoalCommandAction.Clear) + { + bool cleared = await _sessionManager.ClearGoalAsync(resolved.Session.Id, cancellationToken).ConfigureAwait(false); + await ReplyAsync( + sender, + message, + cleared ? $"Goal cleared for {resolved.Session.Name}." : $"No goal was set for {resolved.Session.Name}.", + null, + cancellationToken).ConfigureAwait(false); + return; + } + + string prefix = request.Action switch + { + GoalCommandAction.Set => "Goal set.", + GoalCommandAction.Pause => "Goal paused.", + GoalCommandAction.Resume => "Goal resumed.", + GoalCommandAction.Complete => "Goal marked complete.", + _ => string.Empty, + }; + await ReplyAsync(sender, message, FormatGoal(resolved.Session, goal, prefix), null, cancellationToken).ConfigureAwait(false); + } + catch (CodexCapabilityNotSupportedException exception) + { + _logger.LogWarning(exception, "Codex goal command failed because the configured backend does not support thread goals."); + await ReplyAsync(sender, message, "Goals are unavailable: use the app-server backend with an up-to-date Codex CLI.", null, cancellationToken).ConfigureAwait(false); + } + catch (CodexMethodNotFoundException exception) + { + _logger.LogWarning(exception, "Codex goal command failed because the installed Codex app-server does not expose thread goals."); + await ReplyAsync(sender, message, "Goals are unavailable: the installed Codex app-server does not expose thread goal methods. Update Codex and try /goal again.", null, cancellationToken).ConfigureAwait(false); + } + catch (ArgumentException exception) + { + await ReplyAsync(sender, message, exception.Message, null, cancellationToken).ConfigureAwait(false); + } + } + private async Task HandleModelMenuAsync( TelegramInboundMessage message, string arguments, @@ -2097,11 +2451,21 @@ private static string BuildNotRoutedMessage(TelegramInboundMessage message) private static bool IsChatNotForumError(Exception exception) => exception.Message.Contains("chat is not a forum", StringComparison.OrdinalIgnoreCase); + private static bool IsLaunchpadRootChat(TelegramInboundMessage message) + => IsForumTopicChat(message) && message.MessageThreadId is null; + private static string BuildTopicCreationUnsupportedMessage(TelegramInboundMessage message) => IsPrivateChat(message) ? "This is a private chat, so /topic new cannot create a Telegram forum topic here. Use /new [name] to create a Codex session in this chat, or run /topic new inside a forum-enabled supergroup." : $"This is a {message.ChatType} chat, so /topic new only works in a forum-enabled supergroup. Use /new [name] to create a Codex session here."; + private static string BuildLaunchpadUnsupportedMessage(TelegramInboundMessage message) + => IsPrivateChat(message) + ? "Launchpad mode only works in a forum-enabled supergroup root. Use /launchpad on in that chat root, then /launch [| ]." + : message.MessageThreadId is null + ? $"Launchpad mode only works from the root of a forum-enabled supergroup. This {message.ChatType} root is not a valid launchpad command." + : "Run /launchpad in the chat root, not inside a topic."; + private static string FormatActionFailurePrefix(ParsedTelegramCommand command, TelegramInboundMessage message) => command.IsCommand ? "Command" @@ -2140,45 +2504,7 @@ private static void TryDeleteAttachments(IReadOnlyList string.Join(Environment.NewLine, [ - "Commands:", - "Use the buttons below for quick navigation between sessions, projects, and help.", - "/help - show this help", - "/whoami - show Telegram user, chat, and topic thread IDs", - "/version - show the running Codex Telegram app version", - "/trust - trust the current group or forum chat for allowlisted users", - "/projects - list known project directories", - "/project add - add and select a project", - "/project - select a project", - "/topics - list Telegram topics/sessions in this chat", - "/topic list - list Telegram topics/sessions in this chat", - "/topic new [| ] - create a new Telegram forum topic and session in a forum-enabled supergroup", - "/topic attach [sessionId] - bind the current Telegram forum topic to an existing Codex session", - "/topic current - show the active topic/session in this conversation", - "/sessions - show active and Telegram-managed sessions", - "/sessions all [count] - show recent Codex history", - "/new [name] - create and select a Codex session in the active project for this conversation", - "/use - select the active session for this conversation", - "/send - send text to the active session", - "/steer - steer the active turn in the selected session", - "/queue - view, edit, delete, or send queued prompts now", - "/model [model] [thinking ] - show or change the selected session model", - "/thinking - change the selected session thinking effort", - "/tail [count] - show recent output and keep following the session live", - "/status [sessionId] - show session status", - "/usage - show Codex account usage remaining and reset times", - "/doctor - explain authorization, routing, active project/session, workspace roots, and queue state", - "/outbound - show outbound Telegram queue status", - "/stop [sessionId] - gracefully stop a session", - "/restart confirm - explain how to restart this standalone process", - "/kill confirm - hard-stop a session", - "/rename - rename a session", - "/forget - hide a stopped/exited session without deleting logs", - "Plain text and audio in a private chat, trusted group, or topic stay on that conversation's session; if the conversation has none yet, the first message starts one and live output follows automatically.", - "In forum topics, if plain text gets no response, Telegram bot privacy is likely hiding non-command messages; use /send or disable privacy for this bot.", - "Images, documents, and other attachments are forwarded to Codex; voice notes are transcribed with the configured OpenAI transcription model first.", - "Voice/text control phrase: Codex settings model gpt-5.4-mini thinking high: " - ]); + => string.Join(Environment.NewLine, TelegramBotMetadata.BuildHelpLines()); private static string BuildVersionText() => string.Join(Environment.NewLine, [ @@ -2324,11 +2650,14 @@ private static string FormatTopicList( string projectText = string.IsNullOrWhiteSpace(state.ActiveProjectWorkingDirectory) ? string.Empty : $" · project {CodexTextFormatting.ResolveProjectName(state.ActiveProjectWorkingDirectory)}"; + string launchpadText = state.LaunchpadLastTouchedUtc is null + ? string.Empty + : $" · launchpad {FormatLaunchpadCountdown(state.LaunchpadLastTouchedUtc.Value)} left"; string queueText = state.QueuedPromptCount > 0 ? $" · queued {state.QueuedPromptCount.ToString(CultureInfo.InvariantCulture)}" : string.Empty; - builder.AppendLine($"{activeMarker} {index + 1}. {scopeLabel} · {sessionText}{projectText}{queueText}"); + builder.AppendLine($"{activeMarker} {index + 1}. {scopeLabel} · {sessionText}{projectText}{launchpadText}{queueText}"); } builder.AppendLine("Open a Telegram topic and send a message there to keep work isolated."); @@ -2385,6 +2714,43 @@ private static string FormatStatus(CodexSessionSummary session, CodexSessionMode return builder.ToString().TrimEnd(); } + private static string FormatGoal(CodexSessionSummary session, CodexThreadGoalVm? goal, string? prefix = null) + { + StringBuilder builder = new(); + if (!string.IsNullOrWhiteSpace(prefix)) + { + builder.AppendLine(prefix); + } + + if (goal is null) + { + builder.AppendLine($"No goal is set for {session.Name}."); + builder.AppendLine("Use /goal to set one."); + return builder.ToString().TrimEnd(); + } + + builder.AppendLine($"Goal for {session.Name}"); + builder.AppendLine($"Status: {FormatGoalStatus(goal.Status)}"); + builder.AppendLine($"Objective: {goal.Objective}"); + if (goal.TokenBudget.HasValue) + { + builder.AppendLine($"Tokens: {goal.TokensUsed.ToString(CultureInfo.InvariantCulture)} / {goal.TokenBudget.Value.ToString(CultureInfo.InvariantCulture)}"); + } + else if (goal.TokensUsed > 0) + { + builder.AppendLine($"Tokens used: {goal.TokensUsed.ToString(CultureInfo.InvariantCulture)}"); + } + + if (goal.TimeUsedSeconds > 0) + { + builder.AppendLine($"Time used: {FormatDuration(TimeSpan.FromSeconds(goal.TimeUsedSeconds))}"); + } + + builder.AppendLine($"Updated: {FormatRelativeAge(goal.UpdatedAt)}"); + builder.AppendLine("Use /goal clear, /goal pause, /goal resume, or /goal complete to change status."); + return builder.ToString().TrimEnd(); + } + private static string FormatAccountUsage(CodexAccountUsageVm usage) { if (usage.RateLimits.Count == 0) @@ -2906,6 +3272,9 @@ private async Task AppendDoctorProjectAndSessionAsync(StringBuilder builder, Tel builder.AppendLine(activeSession is null ? "- Active session: " : $"- Active session: {activeSession.Name} ({FormatStatusValue(activeSession.Status)}, {FormatRelativeAge(activeSession.LastActivityUtc)})"); + + TelegramLaunchpadState? launchpad = await _stateStore.GetLaunchpadStateAsync(message.ConversationScope, cancellationToken).ConfigureAwait(false); + builder.AppendLine(FormatLaunchpadStateLine(launchpad)); } catch (Exception exception) { @@ -3008,6 +3377,12 @@ private async Task BuildDoctorNextActionAsync(TelegramInboundMessage mes return "Next: use /projects and /sessions to refresh local state; check the terminal logs if either command fails."; } + TelegramLaunchpadState? launchpad = await _stateStore.GetLaunchpadStateAsync(message.ConversationScope, cancellationToken).ConfigureAwait(false); + if (launchpad is not null && !TelegramLaunchpadPolicy.IsExpired(launchpad.LastTouchedUtc, DateTimeOffset.UtcNow)) + { + return "Next: use /launch [| ] to create a new forum topic and session, or /launchpad off to disarm."; + } + return "Next: send a normal message to continue, or use /status, /tail, /usage, /model, /thinking, and /outbound if something seems off."; } @@ -3077,6 +3452,33 @@ private static string FormatStatusValue(CodexSessionStatus status) _ => status.ToString().ToLowerInvariant(), }; + private static string FormatGoalStatus(CodexThreadGoalStatus status) + => status switch + { + CodexThreadGoalStatus.BudgetLimited => "budget-limited", + _ => status.ToString().ToLowerInvariant(), + }; + + private static string FormatDuration(TimeSpan duration) + { + if (duration < TimeSpan.FromMinutes(1)) + { + return $"{Math.Max(1, (int)Math.Ceiling(duration.TotalSeconds)).ToString(CultureInfo.InvariantCulture)}s"; + } + + if (duration < TimeSpan.FromHours(1)) + { + return $"{(int)duration.TotalMinutes}m"; + } + + if (duration < TimeSpan.FromDays(1)) + { + return $"{(int)duration.TotalHours}h {(duration.Minutes).ToString(CultureInfo.InvariantCulture)}m"; + } + + return $"{(int)duration.TotalDays}d {(duration.Hours).ToString(CultureInfo.InvariantCulture)}h"; + } + private static string FormatRelativeAge(DateTimeOffset value) { TimeSpan age = DateTimeOffset.UtcNow - value.ToUniversalTime(); @@ -3106,6 +3508,32 @@ private static string FormatRelativeAge(DateTimeOffset value) private static string FormatNullableAge(DateTimeOffset? value) => value is null ? "" : FormatRelativeAge(value.Value); + private static string FormatLaunchpadCountdown(DateTimeOffset lastTouchedUtc) + { + TimeSpan remaining = TelegramLaunchpadPolicy.GetRemaining(lastTouchedUtc, DateTimeOffset.UtcNow); + if (remaining <= TimeSpan.Zero) + { + return "expired"; + } + + return TelegramLaunchpadPolicy.FormatRemaining(remaining); + } + + private static string FormatLaunchpadStateLine(TelegramLaunchpadState? launchpad) + { + if (launchpad is null) + { + return "- Launchpad: off"; + } + + if (TelegramLaunchpadPolicy.IsExpired(launchpad.LastTouchedUtc, DateTimeOffset.UtcNow)) + { + return "- Launchpad: expired"; + } + + return $"- Launchpad: armed ({FormatLaunchpadCountdown(launchpad.LastTouchedUtc)} left)"; + } + private static string BuildSelectedSessionReply( string action, CodexSessionSummary session, @@ -3177,6 +3605,69 @@ private static string BuildDefaultTopicName(ProjectChoice project) return topicName.Length <= 120 ? topicName : topicName[..120].TrimEnd(); } + private static string BuildLaunchpadTopicName(string? chatTitle, int sequence) + { + string baseName = NormalizeTopicNameComponent(chatTitle); + if (string.IsNullOrWhiteSpace(baseName)) + { + baseName = "Group Chat"; + } + + string suffix = $" lane {sequence.ToString(CultureInfo.InvariantCulture)}"; + const int maxLength = 120; + int maxBaseLength = Math.Max(1, maxLength - suffix.Length); + if (baseName.Length > maxBaseLength) + { + baseName = baseName[..maxBaseLength].TrimEnd(); + } + + return $"{baseName}{suffix}"; + } + + private static string BuildLaunchNameFromText(string value) + { + string topicName = NormalizeTopicNameComponent(value); + if (string.IsNullOrWhiteSpace(topicName)) + { + topicName = "Launch"; + } + + return TruncateTopicName(topicName); + } + + private static string NormalizeTopicNameComponent(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + string trimmed = value.Trim(); + StringBuilder builder = new(trimmed.Length); + bool pendingSpace = false; + foreach (char ch in trimmed) + { + if (char.IsWhiteSpace(ch)) + { + if (!pendingSpace) + { + builder.Append(' '); + pendingSpace = true; + } + + continue; + } + + builder.Append(ch); + pendingSpace = false; + } + + return builder.ToString(); + } + + private static string TruncateTopicName(string topicName, int maxLength = 120) + => topicName.Length <= maxLength ? topicName : topicName[..maxLength].TrimEnd(); + private static string BuildDefaultSessionName(ProjectChoice project) { string projectName = string.IsNullOrWhiteSpace(project.Name) @@ -3361,6 +3852,30 @@ private static SessionListRequest ParseSessionListRequest(string arguments) return new SessionListRequest(includeAll, Math.Clamp(limit, 1, 20)); } + private static GoalCommandRequest ParseGoalCommand(string arguments) + { + string trimmed = arguments.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return new GoalCommandRequest(GoalCommandAction.Show, null, null); + } + + string[] parts = SplitArguments(trimmed, 2); + string keyword = NormalizeToken(parts[0]); + return keyword switch + { + "show" or "status" or "current" => new GoalCommandRequest(GoalCommandAction.Show, null, null), + "clear" or "reset" or "remove" => new GoalCommandRequest(GoalCommandAction.Clear, null, null), + "pause" or "paused" => new GoalCommandRequest(GoalCommandAction.Pause, null, null), + "resume" or "active" or "start" => new GoalCommandRequest(GoalCommandAction.Resume, null, null), + "complete" or "done" => new GoalCommandRequest(GoalCommandAction.Complete, null, null), + "set" => parts.Length < 2 || string.IsNullOrWhiteSpace(parts[1]) + ? new GoalCommandRequest(GoalCommandAction.Invalid, null, null) + : new GoalCommandRequest(GoalCommandAction.Set, parts[1].Trim(), null), + _ => new GoalCommandRequest(GoalCommandAction.Set, trimmed, null), + }; + } + private static string GetShortSessionId(string sessionId) => sessionId.Length <= 8 ? sessionId : sessionId[..8]; @@ -3560,9 +4075,14 @@ private static string[] SplitCallbackData(string? data) private async Task CreateTopicAndSessionAsync( TelegramInboundMessage message, string topicName, + string? sessionName, string? workingDirectory, ITelegramBotMessageSender sender, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + TelegramConversationScope? templateConversation = null, + string rootReplyVerb = "Created", + string topicReplyVerb = "Created and selected", + string? seedPromptText = null) { if (!IsForumTopicChat(message)) { @@ -3590,10 +4110,15 @@ await ReplyAsync( CodexSessionSummary session = await CreateAndSelectSessionAsync( topicScope, - topicName, + sessionName ?? topicName, workingDirectory, cancellationToken).ConfigureAwait(false); + if (templateConversation is not null) + { + await ApplyLaunchpadTemplateSettingsAsync(session.Id, templateConversation.Value, cancellationToken).ConfigureAwait(false); + } + if (!string.IsNullOrWhiteSpace(workingDirectory)) { await _stateStore.SetActiveProjectWorkingDirectoryAsync(topicScope, workingDirectory, cancellationToken).ConfigureAwait(false); @@ -3605,15 +4130,54 @@ await ReplyAsync( await ReplyAsync( sender, message, - $"Created topic {topic.Name} and session {session.Name}. Open the new topic to continue.", + $"{rootReplyVerb} topic {topic.Name} and session {session.Name} in worktree {workingDirectory}. Open the new topic to continue.", null, cancellationToken).ConfigureAwait(false); await ReplyAsync( sender, topicScope, - BuildSelectedSessionReply("Created and selected", session, settings, projectName, usageSummary), + BuildSelectedSessionReply(topicReplyVerb, session, settings, projectName, usageSummary), BuildSessionButtons([session], includeUse: false), cancellationToken).ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(seedPromptText)) + { + TelegramInboundMessage seededMessage = message with + { + Text = seedPromptText, + MessageThreadId = topic.MessageThreadId, + AudioFilePath = null, + Attachments = null, + }; + + await SendToActiveSessionAsync(seededMessage, seedPromptText, sender, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ApplyLaunchpadTemplateSettingsAsync( + string sessionId, + TelegramConversationScope templateConversation, + CancellationToken cancellationToken) + { + ResolvedSession templateSession = await ResolveActiveSessionAsync(templateConversation, cancellationToken).ConfigureAwait(false); + if (templateSession.Session is null) + { + return; + } + + try + { + CodexSessionModelSettings templateSettings = await _sessionManager.GetModelSettingsAsync(templateSession.Session.Id, cancellationToken).ConfigureAwait(false); + await _sessionManager.UpdateModelSettingsAsync( + sessionId, + templateSettings.Model, + templateSettings.ReasoningEffort, + cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Launchpad template settings could not be applied to session {SessionId}.", sessionId); + } } private static TelegramInboundMessage ToMessage(TelegramInboundCallback callback) @@ -3642,6 +4206,19 @@ private sealed record ModelControlRequest(string? Model, string? ReasoningEffort private sealed record SessionListRequest(bool IncludeAll, int Limit); + private sealed record GoalCommandRequest(GoalCommandAction Action, string? Objective, long? TokenBudget); + + private enum GoalCommandAction + { + Invalid, + Show, + Set, + Clear, + Pause, + Resume, + Complete, + } + private sealed record SessionListView( IReadOnlyList Sessions, int TotalSessionCount, diff --git a/src/Incursa.Codex.Telegram/Telegram/TelegramCodexBotHostedService.cs b/src/Incursa.Codex.Telegram/Telegram/TelegramCodexBotHostedService.cs index 4f48dad..1dd2d76 100644 --- a/src/Incursa.Codex.Telegram/Telegram/TelegramCodexBotHostedService.cs +++ b/src/Incursa.Codex.Telegram/Telegram/TelegramCodexBotHostedService.cs @@ -244,7 +244,8 @@ await sender.SendTextMessageAsync( message.Chat.Id, message.Chat.Type.ToString(), text, - message.MessageThreadId); + message.MessageThreadId, + ChatTitle: message.Chat.Title); if (attachments is { Count: > 0 }) { @@ -313,7 +314,8 @@ private async Task HandleAudioMessageAsync( message.Chat.Type.ToString(), null, message.MessageThreadId, - tempAudioPath); + tempAudioPath, + ChatTitle: message.Chat.Title); await _handler.HandleMessageAsync(inbound, sender, cancellationToken).ConfigureAwait(false); } diff --git a/src/Incursa.Codex.Telegram/Telegram/TelegramLaunchpadExpirationHostedService.cs b/src/Incursa.Codex.Telegram/Telegram/TelegramLaunchpadExpirationHostedService.cs new file mode 100644 index 0000000..4bfa3c2 --- /dev/null +++ b/src/Incursa.Codex.Telegram/Telegram/TelegramLaunchpadExpirationHostedService.cs @@ -0,0 +1,107 @@ +using System.Globalization; +using Incursa.Codex.Telegram.Options; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Incursa.Codex.Telegram.Telegram; + +internal sealed class TelegramLaunchpadExpirationHostedService : BackgroundService +{ + private static readonly TimeSpan SweepInterval = TimeSpan.FromMinutes(1); + + private readonly ITelegramBotStateStore _stateStore; + private readonly ITelegramBotMessageSender _sender; + private readonly TelegramBotOptions _options; + private readonly ILogger _logger; + + public TelegramLaunchpadExpirationHostedService( + ITelegramBotStateStore stateStore, + ITelegramBotMessageSender sender, + IOptions options, + ILogger logger) + { + _stateStore = stateStore; + _sender = sender; + _options = options.Value; + _logger = logger; + } + + internal async Task SweepOnceAsync(CancellationToken cancellationToken) + { + if (!_options.Enabled) + { + return; + } + + IReadOnlyCollection conversationStates; + try + { + conversationStates = await _stateStore.ListConversationStatesAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning(exception, "Launchpad expiry sweep failed while reading Telegram state."); + return; + } + + DateTimeOffset now = DateTimeOffset.UtcNow; + foreach (TelegramConversationState state in conversationStates.Where(state => + state.Scope.MessageThreadId is null + && state.LaunchpadLastTouchedUtc is not null + && TelegramLaunchpadPolicy.IsExpired(state.LaunchpadLastTouchedUtc.Value, now))) + { + bool removed; + try + { + removed = await _stateStore.ClearLaunchpadStateAsync(state.Scope, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + exception, + "Launchpad expiry sweep could not clear state for chat {ChatId}.", + state.Scope.ChatId); + continue; + } + + if (!removed) + { + continue; + } + + try + { + await _sender.SendTextMessageAsync( + state.Scope, + $"Launchpad expired after {TelegramLaunchpadPolicy.InactivityTimeout.TotalMinutes.ToString(CultureInfo.InvariantCulture)} minutes of inactivity. Use /launchpad on to arm it again.", + null, + cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + exception, + "Launchpad expiry notification failed for chat {ChatId}.", + state.Scope.ChatId); + } + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_options.Enabled) + { + _logger.LogInformation("Telegram launchpad expiry sweep is disabled because Telegram is disabled."); + return; + } + + await SweepOnceAsync(stoppingToken).ConfigureAwait(false); + + using PeriodicTimer timer = new(SweepInterval); + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + await SweepOnceAsync(stoppingToken).ConfigureAwait(false); + } + } +} diff --git a/src/Incursa.Codex.Telegram/Telegram/TelegramLaunchpadPolicy.cs b/src/Incursa.Codex.Telegram/Telegram/TelegramLaunchpadPolicy.cs new file mode 100644 index 0000000..dab3edc --- /dev/null +++ b/src/Incursa.Codex.Telegram/Telegram/TelegramLaunchpadPolicy.cs @@ -0,0 +1,40 @@ +namespace Incursa.Codex.Telegram.Telegram; + +internal static class TelegramLaunchpadPolicy +{ + internal static readonly TimeSpan InactivityTimeout = TimeSpan.FromMinutes(10); + + internal static bool IsExpired(DateTimeOffset lastTouchedUtc, DateTimeOffset now) + => GetRemaining(lastTouchedUtc, now) <= TimeSpan.Zero; + + internal static DateTimeOffset GetExpiresAtUtc(DateTimeOffset lastTouchedUtc) + => lastTouchedUtc.ToUniversalTime().Add(InactivityTimeout); + + internal static TimeSpan GetRemaining(DateTimeOffset lastTouchedUtc, DateTimeOffset now) + => GetExpiresAtUtc(lastTouchedUtc) - now.ToUniversalTime(); + + internal static string FormatRemaining(TimeSpan remaining) + { + if (remaining <= TimeSpan.Zero) + { + return "0s"; + } + + if (remaining < TimeSpan.FromMinutes(1)) + { + return $"{Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds))}s"; + } + + if (remaining < TimeSpan.FromHours(1)) + { + return $"{(int)Math.Ceiling(remaining.TotalMinutes)}m"; + } + + if (remaining < TimeSpan.FromDays(1)) + { + return $"{(int)Math.Ceiling(remaining.TotalHours)}h"; + } + + return $"{(int)Math.Ceiling(remaining.TotalDays)}d"; + } +} diff --git a/tests/Incursa.Codex.Telegram.Tests/GitWorktreeProvisionerTests.cs b/tests/Incursa.Codex.Telegram.Tests/GitWorktreeProvisionerTests.cs new file mode 100644 index 0000000..969e34b --- /dev/null +++ b/tests/Incursa.Codex.Telegram.Tests/GitWorktreeProvisionerTests.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; +using System.Globalization; +using Incursa.Codex.Telegram.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Incursa.Codex.Telegram.Tests; + +public sealed class GitWorktreeProvisionerTests +{ + [Fact] + public async Task CreateLaunchWorktreeAsync_CreatesDetachedWorktreeAndPreservesSourceSubdirectory() + { + string tempRoot = Path.Combine(Path.GetTempPath(), "codex-telegram-worktree-tests", Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(tempRoot); + + try + { + string repoRoot = Path.Combine(tempRoot, "source-repo"); + string sourceSubdirectory = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(sourceSubdirectory); + + RunGit(repoRoot, "init", "-q"); + RunGit(repoRoot, "config", "user.email", "test@example.com"); + RunGit(repoRoot, "config", "user.name", "Test User"); + await File.WriteAllTextAsync(Path.Combine(sourceSubdirectory, "README.md"), "hello", CancellationToken.None); + RunGit(repoRoot, "add", "."); + RunGit(repoRoot, "commit", "-q", "-m", "init"); + + GitWorktreeProvisioner provisioner = new(NullLogger.Instance); + GitWorktreeProvisioningResult result = await provisioner.CreateLaunchWorktreeAsync( + sourceSubdirectory, + tempRoot, + "Release readiness", + CancellationToken.None); + + Assert.StartsWith(tempRoot, result.WorktreeRoot, StringComparison.OrdinalIgnoreCase); + Assert.True(Directory.Exists(result.WorktreeRoot)); + Assert.True(File.Exists(Path.Combine(result.WorktreeRoot, ".git"))); + Assert.True(Directory.Exists(result.WorkingDirectory)); + Assert.Equal(Path.Combine(result.WorktreeRoot, "src"), result.WorkingDirectory); + Assert.Equal("HEAD", RunGit(result.WorktreeRoot, "rev-parse", "--abbrev-ref", "HEAD").Trim()); + string gitWorktreeList = RunGit(repoRoot, "worktree", "list", "--porcelain").Replace('\\', '/'); + Assert.Contains(result.WorktreeRoot.Replace('\\', '/'), gitWorktreeList); + } + finally + { + if (Directory.Exists(tempRoot)) + { + try + { + Directory.Delete(tempRoot, recursive: true); + } + catch + { + } + } + } + } + + private static string RunGit(string workingDirectory, params string[] arguments) + { + ProcessStartInfo startInfo = new() + { + FileName = "git", + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start git."); + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"git failed with exit code {process.ExitCode}: {stderr}{Environment.NewLine}{stdout}"); + } + + return stdout; + } +} diff --git a/tests/Incursa.Codex.Telegram.Tests/TelegramBotMetadataSyncServiceTests.cs b/tests/Incursa.Codex.Telegram.Tests/TelegramBotMetadataSyncServiceTests.cs new file mode 100644 index 0000000..f445011 --- /dev/null +++ b/tests/Incursa.Codex.Telegram.Tests/TelegramBotMetadataSyncServiceTests.cs @@ -0,0 +1,96 @@ +using Incursa.Codex.Telegram.Options; +using Incursa.Codex.Telegram.Telegram; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Telegram.Bot.Types; + +namespace Incursa.Codex.Telegram.Tests; + +public sealed class TelegramBotMetadataSyncServiceTests +{ + [Fact] + public async Task SyncAsync_WhenDisabledDoesNotCallTelegram() + { + FakeTelegramBotProfileApiClient client = new(); + TelegramBotMetadataSyncService service = CreateService(enabled: false, client); + + await service.SyncAsync(CancellationToken.None); + + Assert.Empty(client.Commands); + Assert.Empty(client.Descriptions); + Assert.Empty(client.ShortDescriptions); + Assert.Empty(client.DefaultGroupAdministratorRights); + } + + [Fact] + public async Task SyncAsync_AppliesCommandsDescriptionShortDescriptionAndDefaultRights() + { + FakeTelegramBotProfileApiClient client = new(); + TelegramBotMetadataSyncService service = CreateService(enabled: true, client); + + await service.SyncAsync(CancellationToken.None); + + BotCommand[] commands = Assert.Single(client.Commands); + Assert.Equal("help", commands[0].Command); + Assert.Equal("show this help", commands[0].Description); + Assert.Contains(commands, command => command.Command == "queue"); + Assert.Contains(commands, command => command.Command == "restart"); + Assert.Contains(commands, command => command.Command == "launchpad"); + Assert.Contains(commands, command => command.Command == "launch"); + Assert.Contains(commands, command => command.Command == "goal"); + Assert.Equal(["Controls a local Codex installation from Telegram and routes prompts to sessions."], client.Descriptions); + Assert.Equal(["Control local Codex sessions from Telegram."], client.ShortDescriptions); + Assert.Single(client.DefaultGroupAdministratorRights); + ChatAdministratorRights rights = client.DefaultGroupAdministratorRights[0]; + Assert.True(rights.CanManageTopics); + Assert.False(rights.CanDeleteMessages); + Assert.False(rights.CanInviteUsers); + } + + private static TelegramBotMetadataSyncService CreateService( + bool enabled, + FakeTelegramBotProfileApiClient client) + => new( + new TelegramBotOptions + { + Enabled = enabled, + Token = "123:token", + }, + NullLogger.Instance, + client); + + private sealed class FakeTelegramBotProfileApiClient : ITelegramBotProfileApiClient + { + public List Commands { get; } = []; + + public List Descriptions { get; } = []; + + public List ShortDescriptions { get; } = []; + + public List DefaultGroupAdministratorRights { get; } = []; + + public Task SetMyCommandsAsync(IEnumerable commands, CancellationToken cancellationToken) + { + Commands.Add(commands.ToArray()); + return Task.CompletedTask; + } + + public Task SetMyDescriptionAsync(string description, CancellationToken cancellationToken) + { + Descriptions.Add(description); + return Task.CompletedTask; + } + + public Task SetMyShortDescriptionAsync(string shortDescription, CancellationToken cancellationToken) + { + ShortDescriptions.Add(shortDescription); + return Task.CompletedTask; + } + + public Task SetMyDefaultAdministratorRightsAsync(ChatAdministratorRights rights, CancellationToken cancellationToken) + { + DefaultGroupAdministratorRights.Add(rights); + return Task.CompletedTask; + } + } +} diff --git a/tests/Incursa.Codex.Telegram.Tests/TelegramBotStateStoreTests.cs b/tests/Incursa.Codex.Telegram.Tests/TelegramBotStateStoreTests.cs index 69113d2..9af3ae4 100644 --- a/tests/Incursa.Codex.Telegram.Tests/TelegramBotStateStoreTests.cs +++ b/tests/Incursa.Codex.Telegram.Tests/TelegramBotStateStoreTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Incursa.Codex.Telegram.Options; using Incursa.Codex.Telegram.Telegram; using Microsoft.Extensions.Options; @@ -47,6 +48,44 @@ public async Task ListConversationStatesReturnsPersistedConversationScopes() && state.ActiveSessionId == "thread-topic"); } + [Fact] + public async Task LaunchpadStatePersistsAndCanBeCleared() + { + using TemporaryDirectory temp = TemporaryDirectory.Create(); + TelegramConversationScope conversation = new(1234, null); + TelegramBotStateStore store = CreateStore(temp.Path); + DateTimeOffset lastTouched = DateTimeOffset.Parse("2026-05-06T12:34:56Z", CultureInfo.InvariantCulture); + + await store.SetLaunchpadStateAsync(conversation, lastTouched, CancellationToken.None); + + TelegramBotStateStore reloaded = CreateStore(temp.Path); + TelegramLaunchpadState? launchpad = await reloaded.GetLaunchpadStateAsync(conversation, CancellationToken.None); + Assert.NotNull(launchpad); + Assert.Equal(lastTouched, launchpad!.LastTouchedUtc); + + TelegramConversationState conversationState = Assert.Single(await reloaded.ListConversationStatesAsync(CancellationToken.None)); + Assert.Equal(lastTouched, conversationState.LaunchpadLastTouchedUtc); + + Assert.True(await reloaded.ClearLaunchpadStateAsync(conversation, CancellationToken.None)); + Assert.Null(await reloaded.GetLaunchpadStateAsync(conversation, CancellationToken.None)); + Assert.False(await reloaded.ClearLaunchpadStateAsync(conversation, CancellationToken.None)); + } + + [Fact] + public async Task LaunchpadTopicSequencesPersistAndArePerChat() + { + using TemporaryDirectory temp = TemporaryDirectory.Create(); + TelegramBotStateStore store = CreateStore(temp.Path); + + Assert.Equal(1, await store.ReserveLaunchpadTopicSequenceAsync(-1001, CancellationToken.None)); + Assert.Equal(2, await store.ReserveLaunchpadTopicSequenceAsync(-1001, CancellationToken.None)); + Assert.Equal(1, await store.ReserveLaunchpadTopicSequenceAsync(-2002, CancellationToken.None)); + + TelegramBotStateStore reloaded = CreateStore(temp.Path); + Assert.Equal(3, await reloaded.ReserveLaunchpadTopicSequenceAsync(-1001, CancellationToken.None)); + Assert.Equal(2, await reloaded.ReserveLaunchpadTopicSequenceAsync(-2002, CancellationToken.None)); + } + [Fact] public async Task TrustedChatsPersistAndCanBeRemoved() { diff --git a/tests/Incursa.Codex.Telegram.Tests/TelegramCommandHandlerTests.cs b/tests/Incursa.Codex.Telegram.Tests/TelegramCommandHandlerTests.cs index 31a583f..9c3e8fb 100644 --- a/tests/Incursa.Codex.Telegram.Tests/TelegramCommandHandlerTests.cs +++ b/tests/Incursa.Codex.Telegram.Tests/TelegramCommandHandlerTests.cs @@ -403,15 +403,15 @@ await harness.Handler.HandleMessageAsync( harness.Sender, CancellationToken.None); - SentTelegramMessage sent = Assert.Single(harness.Sender.Sent); - Assert.Contains("/projects", sent.Text); - Assert.Contains("/sessions", sent.Text); - Assert.Contains("/model", sent.Text); - Assert.Contains("/version", sent.Text); - Assert.Contains("/queue", sent.Text); - Assert.Contains("/outbound", sent.Text); - Assert.Contains("configured OpenAI transcription model", sent.Text); - Assert.Equal(["Sessions", "Projects", "Help"], FlattenButtonLabels(sent)); + string text = string.Join(Environment.NewLine, harness.Sender.Sent.Select(sent => sent.Text)); + Assert.Contains("/projects", text); + Assert.Contains("/sessions", text); + Assert.Contains("/model", text); + Assert.Contains("/version", text); + Assert.Contains("/queue", text); + Assert.Contains("/outbound", text); + Assert.Contains("configured OpenAI transcription model", text); + Assert.Equal(["Sessions", "Projects", "Help"], FlattenButtonLabels(harness.Sender.Sent.Last())); } [Fact] @@ -683,6 +683,188 @@ await harness.Handler.HandleMessageAsync( Assert.Contains("/new [name]", text); } + [Fact] + public async Task HandleMessageAsync_LaunchpadArmsRootAndLaunchCreatesForumTopicSession() + { + using CommandHandlerHarness harness = CommandHandlerHarness.Create(new TelegramBotOptions + { + AllowedUserIds = [1234], + AllowedChatIds = [-1005555], + }); + string projectPath = harness.Temp.CreateDirectory("repo"); + string worktreePath = Path.Combine(harness.Temp.Path, "worktrees", "release-readiness"); + harness.WorktreeProvisioner.Result = new GitWorktreeProvisioningResult(projectPath, worktreePath, worktreePath); + TelegramConversationScope rootConversation = new(-1005555, null); + string chatTitle = "Launch Pad Group"; + harness.ProjectCatalog.Projects.Add(new CodexProjectCatalogRecord + { + WorkingDirectory = projectPath, + AddedAt = DateTimeOffset.Parse("2026-05-04T00:00:00Z", CultureInfo.InvariantCulture), + }); + harness.SessionManager.Sessions.Add(CreateSession("thread-template", "Template session", projectPath)); + await harness.StateStore.SetActiveProjectWorkingDirectoryAsync(rootConversation, projectPath, CancellationToken.None); + await harness.StateStore.SetActiveSessionIdAsync(rootConversation, "thread-template", CancellationToken.None); + + await harness.Handler.HandleMessageAsync( + new TelegramInboundMessage(1234, rootConversation.ChatId, "supergroup", "/launchpad on", ChatTitle: chatTitle), + harness.Sender, + CancellationToken.None); + await harness.Handler.HandleMessageAsync( + new TelegramInboundMessage(1234, rootConversation.ChatId, "supergroup", "/launch Release readiness | " + projectPath, ChatTitle: chatTitle), + harness.Sender, + CancellationToken.None); + + TelegramLaunchpadState? launchpad = await harness.StateStore.GetLaunchpadStateAsync(rootConversation, CancellationToken.None); + Assert.NotNull(launchpad); + Assert.Single(harness.TopicService.CreateRequests); + Assert.Equal((rootConversation.ChatId, "Release readiness"), harness.TopicService.CreateRequests[0]); + + CreateCodexSessionRequest createRequest = Assert.Single(harness.SessionManager.CreateRequests); + Assert.Equal("Release readiness", createRequest.Name); + Assert.Equal(worktreePath, createRequest.WorkingDirectory); + Assert.Equal(["Release readiness"], harness.SessionManager.SendRequests); + Assert.Single(harness.WorktreeProvisioner.Requests); + Assert.Equal((projectPath, harness.Temp.Path, "Release readiness"), harness.WorktreeProvisioner.Requests[0]); + Assert.Contains(harness.ProjectCatalog.Projects, project => string.Equals(project.WorkingDirectory, worktreePath, StringComparison.OrdinalIgnoreCase)); + Assert.Contains( + harness.SessionManager.UpdateRequests, + update => update.SessionId == "thread-1" + && update.Model == "gpt-5.4-mini" + && update.ReasoningEffort == "high"); + + TelegramConversationScope topicConversation = new(rootConversation.ChatId, 123); + Assert.Equal("thread-1", await harness.StateStore.GetActiveSessionIdAsync(topicConversation, CancellationToken.None)); + Assert.Equal(worktreePath, await harness.StateStore.GetActiveProjectWorkingDirectoryAsync(topicConversation, CancellationToken.None)); + Assert.Equal(4, harness.Sender.Sent.Count); + Assert.Contains("Launchpad is armed", harness.Sender.Sent[0].Text); + Assert.Contains("Launched topic Release readiness and session Release readiness", harness.Sender.Sent[1].Text); + Assert.Contains("Launched and selected Release readiness.", harness.Sender.Sent[2].Text); + Assert.Contains("Sent to Release readiness", harness.Sender.Sent[3].Text); + } + + [Fact] + public async Task HandleMessageAsync_LaunchpadArmsRootPlainTextLaunchSeedsForumTopicSession() + { + using CommandHandlerHarness harness = CommandHandlerHarness.Create(new TelegramBotOptions + { + AllowedUserIds = [1234], + AllowedChatIds = [-1005555], + }); + string projectPath = harness.Temp.CreateDirectory("repo"); + string worktreePath = Path.Combine(harness.Temp.Path, "worktrees", "release-readiness-text"); + harness.WorktreeProvisioner.Result = new GitWorktreeProvisioningResult(projectPath, worktreePath, worktreePath); + TelegramConversationScope rootConversation = new(-1005555, null); + string chatTitle = "Launch Pad Group"; + harness.ProjectCatalog.Projects.Add(new CodexProjectCatalogRecord + { + WorkingDirectory = projectPath, + AddedAt = DateTimeOffset.Parse("2026-05-04T00:00:00Z", CultureInfo.InvariantCulture), + }); + harness.SessionManager.Sessions.Add(CreateSession("thread-template", "Template session", projectPath)); + await harness.StateStore.SetActiveProjectWorkingDirectoryAsync(rootConversation, projectPath, CancellationToken.None); + await harness.StateStore.SetActiveSessionIdAsync(rootConversation, "thread-template", CancellationToken.None); + + await harness.Handler.HandleMessageAsync( + new TelegramInboundMessage(1234, rootConversation.ChatId, "supergroup", "/launchpad on", ChatTitle: chatTitle), + harness.Sender, + CancellationToken.None); + await harness.Handler.HandleMessageAsync( + new TelegramInboundMessage(1234, rootConversation.ChatId, "supergroup", "Release readiness | " + projectPath, ChatTitle: chatTitle), + harness.Sender, + CancellationToken.None); + + TelegramLaunchpadState? launchpad = await harness.StateStore.GetLaunchpadStateAsync(rootConversation, CancellationToken.None); + Assert.NotNull(launchpad); + Assert.Single(harness.TopicService.CreateRequests); + Assert.Equal((rootConversation.ChatId, "Launch Pad Group lane 1"), harness.TopicService.CreateRequests[0]); + + CreateCodexSessionRequest createRequest = Assert.Single(harness.SessionManager.CreateRequests); + Assert.Equal("Launch Pad Group lane 1", createRequest.Name); + Assert.Equal(worktreePath, createRequest.WorkingDirectory); + Assert.Equal(["Release readiness"], harness.SessionManager.SendRequests); + Assert.Single(harness.WorktreeProvisioner.Requests); + Assert.Equal((projectPath, harness.Temp.Path, "Launch Pad Group lane 1"), harness.WorktreeProvisioner.Requests[0]); + Assert.Contains(harness.ProjectCatalog.Projects, project => string.Equals(project.WorkingDirectory, worktreePath, StringComparison.OrdinalIgnoreCase)); + Assert.Contains( + harness.SessionManager.UpdateRequests, + update => update.SessionId == "thread-1" + && update.Model == "gpt-5.4-mini" + && update.ReasoningEffort == "high"); + + TelegramConversationScope topicConversation = new(rootConversation.ChatId, 123); + Assert.Equal("thread-1", await harness.StateStore.GetActiveSessionIdAsync(topicConversation, CancellationToken.None)); + Assert.Equal(worktreePath, await harness.StateStore.GetActiveProjectWorkingDirectoryAsync(topicConversation, CancellationToken.None)); + Assert.Equal(4, harness.Sender.Sent.Count); + Assert.Contains("Launchpad is armed", harness.Sender.Sent[0].Text); + Assert.Contains("Launched topic Launch Pad Group lane 1 and session Launch Pad Group lane 1", harness.Sender.Sent[1].Text); + Assert.Contains("Launched and selected Launch Pad Group lane 1.", harness.Sender.Sent[2].Text); + Assert.Contains("Sent to Launch Pad Group lane 1", harness.Sender.Sent[3].Text); + } + + [Fact] + public async Task HandleMessageAsync_LaunchpadArmsRootAudioLaunchSeedsForumTopicSession() + { + using CommandHandlerHarness harness = CommandHandlerHarness.Create(new TelegramBotOptions + { + AllowedUserIds = [1234], + AllowedChatIds = [-1005555], + }); + string projectPath = harness.Temp.CreateDirectory("repo"); + string worktreePath = Path.Combine(harness.Temp.Path, "worktrees", "release-readiness-audio"); + harness.WorktreeProvisioner.Result = new GitWorktreeProvisioningResult(projectPath, worktreePath, worktreePath); + harness.AudioTranscription.Transcript = "Release readiness"; + TelegramConversationScope rootConversation = new(-1005555, null); + string chatTitle = "Launch Pad Group"; + harness.ProjectCatalog.Projects.Add(new CodexProjectCatalogRecord + { + WorkingDirectory = projectPath, + AddedAt = DateTimeOffset.Parse("2026-05-04T00:00:00Z", CultureInfo.InvariantCulture), + }); + harness.SessionManager.Sessions.Add(CreateSession("thread-template", "Template session", projectPath)); + await harness.StateStore.SetActiveProjectWorkingDirectoryAsync(rootConversation, projectPath, CancellationToken.None); + await harness.StateStore.SetActiveSessionIdAsync(rootConversation, "thread-template", CancellationToken.None); + + await harness.Handler.HandleMessageAsync( + new TelegramInboundMessage(1234, rootConversation.ChatId, "supergroup", "/launchpad on", ChatTitle: chatTitle), + harness.Sender, + CancellationToken.None); + await harness.Handler.HandleMessageAsync( + new TelegramInboundMessage(1234, rootConversation.ChatId, "supergroup", null, AudioFilePath: "launch.ogg", ChatTitle: chatTitle), + harness.Sender, + CancellationToken.None); + + Assert.Single(harness.WorktreeProvisioner.Requests); + Assert.Equal((projectPath, harness.Temp.Path, "Launch Pad Group lane 1"), harness.WorktreeProvisioner.Requests[0]); + Assert.Equal(["Release readiness"], harness.SessionManager.SendRequests); + Assert.Equal(worktreePath, Assert.Single(harness.SessionManager.CreateRequests).WorkingDirectory); + Assert.Equal(worktreePath, await harness.StateStore.GetActiveProjectWorkingDirectoryAsync(new TelegramConversationScope(rootConversation.ChatId, 123), CancellationToken.None)); + Assert.Equal(5, harness.Sender.Sent.Count); + Assert.Contains("Here's what I transcribed:", harness.Sender.Sent[1].Text); + Assert.Contains("Launchpad is armed", harness.Sender.Sent[0].Text); + Assert.Contains("Launched topic Launch Pad Group lane 1 and session Launch Pad Group lane 1", harness.Sender.Sent[2].Text); + Assert.Contains("Launched and selected Launch Pad Group lane 1.", harness.Sender.Sent[3].Text); + Assert.Contains("Sent to Launch Pad Group lane 1", harness.Sender.Sent[4].Text); + } + + [Fact] + public async Task HandleMessageAsync_LaunchRequiresArmedLaunchpad() + { + using CommandHandlerHarness harness = CommandHandlerHarness.Create(new TelegramBotOptions + { + AllowedUserIds = [1234], + AllowedChatIds = [-1005555], + }); + + await harness.Handler.HandleMessageAsync( + new TelegramInboundMessage(1234, -1005555, "supergroup", "/launch Release readiness"), + harness.Sender, + CancellationToken.None); + + string text = Assert.Single(harness.Sender.Sent).Text; + Assert.Contains("Launchpad is not armed", text); + Assert.Empty(harness.TopicService.CreateRequests); + } + [Fact] public async Task HandleMessageAsync_TopicAttachAndCurrentUseTopicScopedSession() { @@ -855,6 +1037,71 @@ await updateHarness.Handler.HandleMessageAsync( Assert.Contains("Updated model settings:", Assert.Single(updateHarness.Sender.Sent).Text); } + [Fact] + public async Task HandleMessageAsync_GoalShowsAndSetsActiveSessionGoal() + { + using CommandHandlerHarness harness = CommandHandlerHarness.Create(); + TelegramConversationScope conversation = new(5555, null); + harness.SessionManager.Sessions.Add(CreateSession("thread-1", "Demo session", harness.Temp.Path)); + await harness.StateStore.SetActiveSessionIdAsync(conversation, "thread-1", CancellationToken.None); + + await harness.Handler.HandleMessageAsync( + new TelegramInboundMessage(1234, conversation.ChatId, "private", "/goal"), + harness.Sender, + CancellationToken.None); + + Assert.Contains("No goal is set for Demo session.", Assert.Single(harness.Sender.Sent).Text); + + harness.Sender.Sent.Clear(); + await harness.Handler.HandleMessageAsync( + new TelegramInboundMessage(1234, conversation.ChatId, "private", "/goal Ship the Telegram goal command"), + harness.Sender, + CancellationToken.None); + + Assert.Equal(("thread-1", "Ship the Telegram goal command", (long?)null), Assert.Single(harness.SessionManager.SetGoalRequests)); + string setText = Assert.Single(harness.Sender.Sent).Text; + Assert.Contains("Goal set.", setText); + Assert.Contains("Objective: Ship the Telegram goal command", setText); + + harness.Sender.Sent.Clear(); + await harness.Handler.HandleMessageAsync( + new TelegramInboundMessage(1234, conversation.ChatId, "private", "/goal"), + harness.Sender, + CancellationToken.None); + + string showText = Assert.Single(harness.Sender.Sent).Text; + Assert.Contains("Goal for Demo session", showText); + Assert.Contains("Status: active", showText); + Assert.Contains("Objective: Ship the Telegram goal command", showText); + } + + [Fact] + public async Task HandleMessageAsync_GoalControlCommandsUseActiveSession() + { + using CommandHandlerHarness harness = CommandHandlerHarness.Create(); + TelegramConversationScope conversation = new(5555, null); + harness.SessionManager.Sessions.Add(CreateSession("thread-1", "Demo session", harness.Temp.Path)); + harness.SessionManager.Goals["thread-1"] = CreateGoal("thread-1", "Finish the release", CodexThreadGoalStatus.Active); + await harness.StateStore.SetActiveSessionIdAsync(conversation, "thread-1", CancellationToken.None); + + await harness.Handler.HandleMessageAsync( + new TelegramInboundMessage(1234, conversation.ChatId, "private", "/goal pause"), + harness.Sender, + CancellationToken.None); + + Assert.Equal(("thread-1", CodexThreadGoalStatus.Paused), Assert.Single(harness.SessionManager.SetGoalStatusRequests)); + Assert.Contains("Goal paused.", Assert.Single(harness.Sender.Sent).Text); + + harness.Sender.Sent.Clear(); + await harness.Handler.HandleMessageAsync( + new TelegramInboundMessage(1234, conversation.ChatId, "private", "/goal clear"), + harness.Sender, + CancellationToken.None); + + Assert.Equal("thread-1", Assert.Single(harness.SessionManager.ClearGoalRequests)); + Assert.Contains("Goal cleared for Demo session.", Assert.Single(harness.Sender.Sent).Text); + } + [Fact] public async Task HandleMessageAsync_StatusAndTailUseActiveSession() { @@ -1298,11 +1545,10 @@ await harness.Handler.HandleCallbackAsync( CancellationToken.None); Assert.Equal(["Opening menu.", "Opening menu.", "Opening menu."], harness.Sender.CallbackAnswers.Select(answer => answer.Text)); - Assert.Collection( - harness.Sender.Sent, - sent => Assert.Contains("Demo session", sent.Text), - sent => Assert.Contains("Commands:", sent.Text), - sent => Assert.Equal("Unsupported navigation action.", sent.Text)); + Assert.Contains(harness.Sender.Sent, sent => sent.Text.Contains("Demo session")); + Assert.Contains(harness.Sender.Sent, sent => sent.Text.Contains("Commands:")); + Assert.Contains(harness.Sender.Sent, sent => sent.Text.Contains("configured OpenAI transcription model")); + Assert.Equal("Unsupported navigation action.", harness.Sender.Sent.Last().Text); } [Fact] @@ -1518,6 +1764,21 @@ private static CodexSessionSummary CreateSession(string id, string name, string? null, null); + private static CodexThreadGoalVm CreateGoal( + string threadId, + string objective, + CodexThreadGoalStatus status, + long? tokenBudget = null) + => new( + threadId, + objective, + status, + tokenBudget, + 42, + 120, + DateTimeOffset.Parse("2026-05-09T12:00:00Z", CultureInfo.InvariantCulture), + DateTimeOffset.Parse("2026-05-09T12:02:00Z", CultureInfo.InvariantCulture)); + private static TelegramQueuedPrompt CreateQueuedPrompt( string id, long userId, @@ -1607,14 +1868,15 @@ private sealed class CommandHandlerHarness : IDisposable private CommandHandlerHarness( TemporaryDirectory temp, FakeCodexSessionManager sessionManager, - FakeCodexAccountUsageService accountUsage, - FakeProjectCatalogStore projectCatalog, - TelegramBotStateStore stateStore, - FakeOutboundTelegramQueue outboundQueue, - FakeTelegramForumTopicService topicService, - FakeAudioTranscriptionService audioTranscription, - TestTelegramBotMessageSender sender, - TelegramCodexBotCommandHandler handler) + FakeCodexAccountUsageService accountUsage, + FakeProjectCatalogStore projectCatalog, + TelegramBotStateStore stateStore, + FakeOutboundTelegramQueue outboundQueue, + FakeGitWorktreeProvisioner worktreeProvisioner, + FakeTelegramForumTopicService topicService, + FakeAudioTranscriptionService audioTranscription, + TestTelegramBotMessageSender sender, + TelegramCodexBotCommandHandler handler) { Temp = temp; SessionManager = sessionManager; @@ -1622,6 +1884,7 @@ private CommandHandlerHarness( ProjectCatalog = projectCatalog; StateStore = stateStore; OutboundQueue = outboundQueue; + WorktreeProvisioner = worktreeProvisioner; TopicService = topicService; AudioTranscription = audioTranscription; Sender = sender; @@ -1640,6 +1903,8 @@ private CommandHandlerHarness( public FakeOutboundTelegramQueue OutboundQueue { get; } + public FakeGitWorktreeProvisioner WorktreeProvisioner { get; } + public FakeTelegramForumTopicService TopicService { get; } public FakeAudioTranscriptionService AudioTranscription { get; } @@ -1665,6 +1930,7 @@ public static CommandHandlerHarness Create(TelegramBotOptions? botOptions = null FakeProjectCatalogStore projectCatalog = new(); TelegramBotStateStore stateStore = new(codexOptions); FakeOutboundTelegramQueue outboundQueue = new(); + FakeGitWorktreeProvisioner worktreeProvisioner = new(temp.Path); FakeTelegramForumTopicService topicService = new(); FakeAudioTranscriptionService audioTranscription = new(); TestTelegramBotMessageSender sender = new(); @@ -1681,13 +1947,14 @@ public static CommandHandlerHarness Create(TelegramBotOptions? botOptions = null topicService, audioTranscription, outboundQueue, + worktreeProvisioner, Microsoft.Extensions.Options.Options.Create(botOptions ?? new TelegramBotOptions { AllowedUserIds = [1234], }), NullLogger.Instance); - return new CommandHandlerHarness(temp, sessionManager, accountUsage, projectCatalog, stateStore, outboundQueue, topicService, audioTranscription, sender, handler); + return new CommandHandlerHarness(temp, sessionManager, accountUsage, projectCatalog, stateStore, outboundQueue, worktreeProvisioner, topicService, audioTranscription, sender, handler); } public void Dispose() @@ -1708,6 +1975,14 @@ private sealed class FakeCodexSessionManager : ICodexSessionManager public List<(string SessionId, string? Model, string? ReasoningEffort)> UpdateRequests { get; } = []; + public Dictionary Goals { get; } = new(StringComparer.OrdinalIgnoreCase); + + public List<(string SessionId, string Objective, long? TokenBudget)> SetGoalRequests { get; } = []; + + public List<(string SessionId, CodexThreadGoalStatus Status)> SetGoalStatusRequests { get; } = []; + + public List ClearGoalRequests { get; } = []; + public List<(string SessionId, int LineCount)> TailRequests { get; } = []; public List StopRequests { get; } = []; @@ -1803,6 +2078,39 @@ public Task UpdateModelSettingsAsync( reasoningEffort ?? "high")); } + public Task GetGoalAsync(string sessionId, CancellationToken cancellationToken) + => Task.FromResult(Goals.GetValueOrDefault(sessionId)); + + public Task SetGoalAsync( + string sessionId, + string objective, + long? tokenBudget, + CancellationToken cancellationToken) + { + SetGoalRequests.Add((sessionId, objective, tokenBudget)); + CodexThreadGoalVm goal = CreateGoal(sessionId, objective, CodexThreadGoalStatus.Active, tokenBudget); + Goals[sessionId] = goal; + return Task.FromResult(goal); + } + + public Task SetGoalStatusAsync( + string sessionId, + CodexThreadGoalStatus status, + CancellationToken cancellationToken) + { + SetGoalStatusRequests.Add((sessionId, status)); + CodexThreadGoalVm current = Goals.GetValueOrDefault(sessionId) ?? CreateGoal(sessionId, "Existing goal", CodexThreadGoalStatus.Active); + CodexThreadGoalVm updated = current with { Status = status, UpdatedAt = DateTimeOffset.UtcNow }; + Goals[sessionId] = updated; + return Task.FromResult(updated); + } + + public Task ClearGoalAsync(string sessionId, CancellationToken cancellationToken) + { + ClearGoalRequests.Add(sessionId); + return Task.FromResult(Goals.Remove(sessionId)); + } + public Task TailAsync(string sessionId, int lineCount, CancellationToken cancellationToken) { TailRequests.Add((sessionId, lineCount)); @@ -1909,6 +2217,31 @@ public Task CreateForumTopicAsync(long chatId, } } + private sealed class FakeGitWorktreeProvisioner : IGitWorktreeProvisioner + { + public FakeGitWorktreeProvisioner(string tempPath) + { + Result = new GitWorktreeProvisioningResult( + Path.Combine(tempPath, "repo-root"), + Path.Combine(tempPath, "worktree-root"), + Path.Combine(tempPath, "worktree-root")); + } + + public List<(string SourceWorkingDirectory, string AllowlistedRoot, string LaunchName)> Requests { get; } = []; + + public GitWorktreeProvisioningResult Result { get; set; } + + public Task CreateLaunchWorktreeAsync( + string sourceWorkingDirectory, + string allowlistedRoot, + string launchName, + CancellationToken cancellationToken) + { + Requests.Add((sourceWorkingDirectory, allowlistedRoot, launchName)); + return Task.FromResult(Result); + } + } + private sealed class FakeAudioTranscriptionService : IAudioTranscriptionService { public string Transcript { get; set; } = "transcribed text"; diff --git a/tests/Incursa.Codex.Telegram.Tests/TelegramLaunchpadExpirationHostedServiceTests.cs b/tests/Incursa.Codex.Telegram.Tests/TelegramLaunchpadExpirationHostedServiceTests.cs new file mode 100644 index 0000000..625bec9 --- /dev/null +++ b/tests/Incursa.Codex.Telegram.Tests/TelegramLaunchpadExpirationHostedServiceTests.cs @@ -0,0 +1,69 @@ +using Incursa.Codex.Telegram.Options; +using Incursa.Codex.Telegram.Telegram; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Incursa.Codex.Telegram.Tests; + +public sealed class TelegramLaunchpadExpirationHostedServiceTests +{ + [Fact] + public async Task SweepOnceAsync_ClearsExpiredRootLaunchpadAndNotifiesChat() + { + using TemporaryDirectory temp = TemporaryDirectory.Create(); + TelegramBotStateStore store = CreateStore(temp.Path); + TelegramConversationScope conversation = new(-1005555, null); + await store.SetLaunchpadStateAsync(conversation, DateTimeOffset.UtcNow.AddMinutes(-11), CancellationToken.None); + + TestTelegramBotMessageSender sender = new(); + TelegramLaunchpadExpirationHostedService service = new( + store, + sender, + Microsoft.Extensions.Options.Options.Create(new TelegramBotOptions { Enabled = true }), + NullLogger.Instance); + + await service.SweepOnceAsync(CancellationToken.None); + + Assert.Null(await store.GetLaunchpadStateAsync(conversation, CancellationToken.None)); + SentTelegramMessage sent = Assert.Single(sender.Sent); + Assert.Equal(conversation, sent.Conversation); + Assert.Contains("Launchpad expired after 10 minutes of inactivity", sent.Text); + } + + private static TelegramBotStateStore CreateStore(string dataRoot) + => new(Microsoft.Extensions.Options.Options.Create(new CodexTelegramOptions + { + Workspace = new CodexWorkspaceOptions + { + DataRoot = dataRoot, + }, + })); + + private sealed class TestTelegramBotMessageSender : ITelegramBotMessageSender + { + public List Sent { get; } = []; + + public Task SendTextMessageAsync( + TelegramConversationScope conversation, + string text, + IReadOnlyList>? buttons, + CancellationToken cancellationToken) + { + Sent.Add(new SentTelegramMessage(conversation, text)); + return Task.CompletedTask; + } + + public Task EditTextMessageAsync( + TelegramConversationScope conversation, + int messageId, + string text, + IReadOnlyList>? buttons, + CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task AnswerCallbackQueryAsync(string callbackQueryId, string? text, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + private sealed record SentTelegramMessage(TelegramConversationScope Conversation, string Text); +} diff --git a/tests/Incursa.Codex.Telegram.Tests/TelegramQueuedPromptProcessorTests.cs b/tests/Incursa.Codex.Telegram.Tests/TelegramQueuedPromptProcessorTests.cs index 2492d9c..ab193e6 100644 --- a/tests/Incursa.Codex.Telegram.Tests/TelegramQueuedPromptProcessorTests.cs +++ b/tests/Incursa.Codex.Telegram.Tests/TelegramQueuedPromptProcessorTests.cs @@ -441,6 +441,18 @@ public Task GetModelSettingsAsync(string sessionId, C public Task UpdateModelSettingsAsync(string sessionId, string? model, string? reasoningEffort, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetGoalAsync(string sessionId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task SetGoalAsync(string sessionId, string objective, long? tokenBudget, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task SetGoalStatusAsync(string sessionId, CodexThreadGoalStatus status, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task ClearGoalAsync(string sessionId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + public Task TailAsync(string sessionId, int lineCount, CancellationToken cancellationToken) => throw new NotSupportedException();