|
|
|
|
@@ -70,6 +70,7 @@ type assistantThreadCWDDirective struct {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type outputState struct {
|
|
|
|
|
turnID string
|
|
|
|
|
chatID int64
|
|
|
|
|
assistant strings.Builder
|
|
|
|
|
sentAny bool
|
|
|
|
|
@@ -137,9 +138,15 @@ func NewBot(tg *Client, st *store.Store, codex *codexapp.Client, uploadDir, code
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) Run(ctx context.Context) error {
|
|
|
|
|
if err := b.interruptStoredActiveTurns(ctx); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := b.store.ClearActiveTurns(ctx); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := b.tg.SetMyCommands(ctx, botCommands()); err != nil {
|
|
|
|
|
b.logger.Printf("set telegram commands: %v", err)
|
|
|
|
|
}
|
|
|
|
|
go b.handleCodexEvents(ctx)
|
|
|
|
|
|
|
|
|
|
offset := 0
|
|
|
|
|
@@ -165,6 +172,60 @@ func (b *Bot) Run(ctx context.Context) error {
|
|
|
|
|
return ctx.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) interruptStoredActiveTurns(ctx context.Context) error {
|
|
|
|
|
turns, err := b.store.ListActiveTurns(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if len(turns) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
interruptCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
for _, turn := range turns {
|
|
|
|
|
if strings.TrimSpace(turn.CodexThreadID) == "" || strings.TrimSpace(turn.TurnID) == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if err := b.codex.InterruptTurn(interruptCtx, turn.CodexThreadID, turn.TurnID); err != nil {
|
|
|
|
|
b.logger.Printf("interrupt stale active turn %s/%s: %v", turn.CodexThreadID, turn.TurnID, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) clearStaleActiveTurn(ctx context.Context, userID int64, thread store.Thread, turnID string) {
|
|
|
|
|
turnID = strings.TrimSpace(turnID)
|
|
|
|
|
if turnID == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
interruptCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
|
|
|
if err := b.codex.InterruptTurn(interruptCtx, thread.CodexThreadID, turnID); err != nil {
|
|
|
|
|
b.logger.Printf("interrupt stale active turn %s/%s: %v", thread.CodexThreadID, turnID, err)
|
|
|
|
|
}
|
|
|
|
|
cancel()
|
|
|
|
|
if err := b.store.ClearActiveTurn(ctx, userID, turnID); err != nil {
|
|
|
|
|
b.logger.Printf("clear stale active turn %s for user %d: %v", turnID, userID, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func botCommands() []BotCommand {
|
|
|
|
|
return []BotCommand{
|
|
|
|
|
{Command: "new", Description: "Start a new thread"},
|
|
|
|
|
{Command: "thread", Description: "List or switch threads"},
|
|
|
|
|
{Command: "rename", Description: "Rename a thread"},
|
|
|
|
|
{Command: "fork", Description: "Fork the active thread"},
|
|
|
|
|
{Command: "archive", Description: "Archive a thread"},
|
|
|
|
|
{Command: "status", Description: "Show active settings"},
|
|
|
|
|
{Command: "cancel", Description: "Interrupt the active turn"},
|
|
|
|
|
{Command: "workspace", Description: "Select workspace"},
|
|
|
|
|
{Command: "model", Description: "Choose model"},
|
|
|
|
|
{Command: "sandbox", Description: "Show or set sandbox"},
|
|
|
|
|
{Command: "pic", Description: "Generate images"},
|
|
|
|
|
{Command: "diff", Description: "Show latest diff"},
|
|
|
|
|
{Command: "help", Description: "Show help"},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) handleUpdate(ctx context.Context, update Update) error {
|
|
|
|
|
switch {
|
|
|
|
|
case update.Message != nil:
|
|
|
|
|
@@ -224,10 +285,10 @@ func (b *Bot) handleCommand(ctx context.Context, message *Message, session store
|
|
|
|
|
case "new":
|
|
|
|
|
_, _, err := b.createNewThread(ctx, userID, chatID, session, true)
|
|
|
|
|
return true, err
|
|
|
|
|
case "thread", "threads":
|
|
|
|
|
return true, b.sendThreads(ctx, userID, chatID)
|
|
|
|
|
case "resume":
|
|
|
|
|
return true, b.resumeThread(ctx, userID, chatID, args)
|
|
|
|
|
case "thread":
|
|
|
|
|
return true, b.threadCommand(ctx, userID, chatID, args)
|
|
|
|
|
case "threads", "resume":
|
|
|
|
|
return true, b.legacyThreadCommand(ctx, userID, chatID, args)
|
|
|
|
|
case "rename":
|
|
|
|
|
return true, b.renameThread(ctx, userID, chatID, session, args)
|
|
|
|
|
case "fork":
|
|
|
|
|
@@ -261,9 +322,8 @@ func (b *Bot) sendHelp(ctx context.Context, chatID int64) error {
|
|
|
|
|
"Codex Telegram Bot",
|
|
|
|
|
"",
|
|
|
|
|
"/new - start a new Codex thread",
|
|
|
|
|
"/thread or /threads - list recent threads",
|
|
|
|
|
"/resume - choose a recent thread",
|
|
|
|
|
"/resume ID - resume a thread",
|
|
|
|
|
"/thread - list recent threads",
|
|
|
|
|
"/thread ID - switch to a thread",
|
|
|
|
|
"/rename TITLE or /rename ID TITLE - rename a thread",
|
|
|
|
|
"/fork - fork the active thread",
|
|
|
|
|
"/archive [ID] - archive a thread",
|
|
|
|
|
@@ -281,16 +341,12 @@ func (b *Bot) sendHelp(ctx context.Context, chatID int64) error {
|
|
|
|
|
return b.sendLong(ctx, chatID, text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) sendThreads(ctx context.Context, userID, chatID int64) error {
|
|
|
|
|
return b.sendResumeChoices(ctx, userID, chatID, 0, 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) resumeThread(ctx context.Context, userID, chatID int64, args []string) error {
|
|
|
|
|
func (b *Bot) threadCommand(ctx context.Context, userID, chatID int64, args []string) error {
|
|
|
|
|
if len(args) == 0 {
|
|
|
|
|
return b.sendResumeChoices(ctx, userID, chatID, 0, 0)
|
|
|
|
|
}
|
|
|
|
|
if len(args) != 1 {
|
|
|
|
|
_, err := b.tg.SendMessage(ctx, chatID, "Use /resume to choose a thread, or /resume ID to resume directly.", SendMessageOptions{})
|
|
|
|
|
_, err := b.tg.SendMessage(ctx, chatID, "Use /thread to choose a thread, or /thread ID to switch directly.", SendMessageOptions{})
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
id, err := strconv.ParseInt(args[0], 10, 64)
|
|
|
|
|
@@ -301,6 +357,13 @@ func (b *Bot) resumeThread(ctx context.Context, userID, chatID int64, args []str
|
|
|
|
|
return b.resumeThreadByID(ctx, userID, chatID, id, 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) legacyThreadCommand(ctx context.Context, userID, chatID int64, args []string) error {
|
|
|
|
|
if len(args) == 0 {
|
|
|
|
|
return b.sendResumeChoices(ctx, userID, chatID, 0, 0)
|
|
|
|
|
}
|
|
|
|
|
return b.threadCommand(ctx, userID, chatID, args)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) sendResumeChoices(ctx context.Context, userID, chatID int64, page int, messageID int) error {
|
|
|
|
|
if page < 0 {
|
|
|
|
|
page = 0
|
|
|
|
|
@@ -775,19 +838,24 @@ func (b *Bot) continueThread(ctx context.Context, message *Message, session stor
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if session.ActiveTurnID != "" {
|
|
|
|
|
if err := b.codex.SteerTurn(ctx, thread.CodexThreadID, session.ActiveTurnID, input); err != nil {
|
|
|
|
|
return b.sendError(ctx, chatID, "Could not append to active turn", err)
|
|
|
|
|
if b.hasOutputTurn(thread.CodexThreadID, session.ActiveTurnID) {
|
|
|
|
|
if err := b.codex.SteerTurn(ctx, thread.CodexThreadID, session.ActiveTurnID, input); err != nil {
|
|
|
|
|
return b.sendError(ctx, chatID, "Could not append to active turn", err)
|
|
|
|
|
}
|
|
|
|
|
_, err := b.tg.SendMessage(ctx, chatID, "Added to the running turn.", SendMessageOptions{})
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
_, err := b.tg.SendMessage(ctx, chatID, "Added to the running turn.", SendMessageOptions{})
|
|
|
|
|
return err
|
|
|
|
|
b.clearStaleActiveTurn(ctx, userID, thread, session.ActiveTurnID)
|
|
|
|
|
session.ActiveTurnID = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
b.registerOutput(thread.CodexThreadID, chatID)
|
|
|
|
|
b.registerOutput(thread.CodexThreadID, "", chatID)
|
|
|
|
|
turn, err := b.codex.StartTurn(ctx, thread.CodexThreadID, "", session.Model, session.ReasoningEffort, session.Sandbox, input)
|
|
|
|
|
if err != nil {
|
|
|
|
|
b.clearOutput(thread.CodexThreadID)
|
|
|
|
|
return b.sendError(ctx, chatID, "Codex turn failed", err)
|
|
|
|
|
}
|
|
|
|
|
b.setOutputTurnID(thread.CodexThreadID, turn.ID)
|
|
|
|
|
if err := b.store.SetActiveTurn(ctx, userID, turn.ID); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
@@ -943,21 +1011,26 @@ func (b *Bot) handlePictureCommand(ctx context.Context, userID, chatID int64, se
|
|
|
|
|
_, err := b.tg.SendMessage(ctx, chatID, "Use /pic PROMPT to generate image(s).", SendMessageOptions{})
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if session.ActiveTurnID != "" {
|
|
|
|
|
_, err := b.tg.SendMessage(ctx, chatID, "A Codex turn is already running. Use /cancel first, or wait for it to finish.", SendMessageOptions{})
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
thread, _, err := b.ensureThreadForPicture(ctx, userID, chatID, session)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if session.ActiveTurnID != "" {
|
|
|
|
|
if b.hasOutputTurn(thread.CodexThreadID, session.ActiveTurnID) {
|
|
|
|
|
_, err := b.tg.SendMessage(ctx, chatID, "A Codex turn is already running. Use /cancel first, or wait for it to finish.", SendMessageOptions{})
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
b.clearStaleActiveTurn(ctx, userID, thread, session.ActiveTurnID)
|
|
|
|
|
session.ActiveTurnID = ""
|
|
|
|
|
}
|
|
|
|
|
input := []codexapp.InputItem{{Type: "text", Text: pictureGenerationInstruction(prompt)}}
|
|
|
|
|
b.registerPictureOutput(thread.CodexThreadID, chatID)
|
|
|
|
|
b.registerPictureOutput(thread.CodexThreadID, "", chatID)
|
|
|
|
|
turn, err := b.codex.StartTurn(ctx, thread.CodexThreadID, "", session.Model, session.ReasoningEffort, session.Sandbox, input)
|
|
|
|
|
if err != nil {
|
|
|
|
|
b.clearOutput(thread.CodexThreadID)
|
|
|
|
|
return b.sendError(ctx, chatID, "Codex image generation failed", err)
|
|
|
|
|
}
|
|
|
|
|
b.setOutputTurnID(thread.CodexThreadID, turn.ID)
|
|
|
|
|
if err := b.store.SetActiveTurn(ctx, userID, turn.ID); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
@@ -1196,7 +1269,7 @@ func parseCodexThreadItem(raw json.RawMessage) (codexThreadItemView, error) {
|
|
|
|
|
func renderCodexItemStarted(item codexThreadItemView) string {
|
|
|
|
|
switch item.Type {
|
|
|
|
|
case "commandExecution":
|
|
|
|
|
return SummaryDetailsRawHTMLLimited("Tool call: command started", commandStartedDetailsHTML(item), TelegramHTMLMessageLimit)
|
|
|
|
|
return SummaryRawHTMLSectionsLimited("Tool call: command started", commandExecutionSectionsHTML(item, ""), TelegramHTMLMessageLimit)
|
|
|
|
|
case "fileChange":
|
|
|
|
|
return "Tool call: file change started"
|
|
|
|
|
case "mcpToolCall":
|
|
|
|
|
@@ -1219,11 +1292,7 @@ func renderCodexItemStarted(item codexThreadItemView) string {
|
|
|
|
|
func renderCodexItemCompleted(item codexThreadItemView) string {
|
|
|
|
|
switch item.Type {
|
|
|
|
|
case "commandExecution":
|
|
|
|
|
status := ""
|
|
|
|
|
if item.ExitCode != nil {
|
|
|
|
|
status = fmt.Sprintf("Exit code: %d", *item.ExitCode)
|
|
|
|
|
}
|
|
|
|
|
return SummaryDetailsRawHTMLLimited(joinNonEmpty("Tool call: command finished", status), renderCodexItemDetailsHTML(item), TelegramHTMLMessageLimit)
|
|
|
|
|
return SummaryRawHTMLSectionsLimited("Tool call: command finished", commandExecutionSectionsHTML(item, ""), TelegramHTMLMessageLimit)
|
|
|
|
|
case "fileChange":
|
|
|
|
|
return joinNonEmpty("Tool call: file change finished", fmt.Sprintf("Changed files: %d", len(item.Changes)), "Status: "+item.Status)
|
|
|
|
|
case "mcpToolCall":
|
|
|
|
|
@@ -1250,14 +1319,38 @@ func renderCodexItemCompleted(item codexThreadItemView) string {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func commandStartedDetailsHTML(item codexThreadItemView) string {
|
|
|
|
|
var parts []string
|
|
|
|
|
if command := strings.TrimSpace(item.Command); command != "" {
|
|
|
|
|
parts = append(parts, "<b>Command</b>", CodeBlockHTML("bash", command))
|
|
|
|
|
}
|
|
|
|
|
return commandExecutionDetailsHTML(item, "")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func commandExecutionDetailsHTML(item codexThreadItemView, editedAt string) string {
|
|
|
|
|
return strings.Join(commandExecutionSectionsHTML(item, editedAt), "\n\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func commandExecutionSectionsHTML(item codexThreadItemView, editedAt string) []string {
|
|
|
|
|
var sections []string
|
|
|
|
|
if cwd := strings.TrimSpace(item.CWD); cwd != "" {
|
|
|
|
|
parts = append(parts, FieldHTML("CWD", cwd))
|
|
|
|
|
sections = append(sections, FieldHTML("CWD", cwd))
|
|
|
|
|
}
|
|
|
|
|
return strings.Join(parts, "\n")
|
|
|
|
|
if command := strings.TrimSpace(item.Command); command != "" {
|
|
|
|
|
sections = append(sections, "<b>Command:</b> "+CodeBlockHTML("bash", command))
|
|
|
|
|
}
|
|
|
|
|
if item.AggregatedOutput != nil && strings.TrimSpace(*item.AggregatedOutput) != "" {
|
|
|
|
|
sections = append(sections, "<b>Output:</b> "+CodeBlockHTML("text", *item.AggregatedOutput))
|
|
|
|
|
}
|
|
|
|
|
var fields []string
|
|
|
|
|
if item.ExitCode != nil {
|
|
|
|
|
fields = append(fields, FieldHTML("Exit code", strconv.Itoa(*item.ExitCode)))
|
|
|
|
|
}
|
|
|
|
|
if item.DurationMs != nil {
|
|
|
|
|
fields = append(fields, FieldHTML("Duration ms", strconv.FormatInt(*item.DurationMs, 10)))
|
|
|
|
|
}
|
|
|
|
|
if editedAt != "" {
|
|
|
|
|
fields = append(fields, FieldHTML("Edited at", editedAt))
|
|
|
|
|
}
|
|
|
|
|
if len(fields) > 0 {
|
|
|
|
|
sections = append(sections, strings.Join(nonEmptyHTML(fields), "\n"))
|
|
|
|
|
}
|
|
|
|
|
return nonEmptyHTML(sections)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func renderCodexItemDetailsHTML(item codexThreadItemView) string {
|
|
|
|
|
@@ -1267,16 +1360,6 @@ func renderCodexItemDetailsHTML(item codexThreadItemView) string {
|
|
|
|
|
parts = append(parts, html)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
appendInt := func(label string, value *int) {
|
|
|
|
|
if value != nil {
|
|
|
|
|
appendField(label, strconv.Itoa(*value))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
appendInt64 := func(label string, value *int64) {
|
|
|
|
|
if value != nil {
|
|
|
|
|
appendField(label, strconv.FormatInt(*value, 10))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
appendBool := func(label string, value *bool) {
|
|
|
|
|
if value != nil {
|
|
|
|
|
appendField(label, strconv.FormatBool(*value))
|
|
|
|
|
@@ -1285,15 +1368,7 @@ func renderCodexItemDetailsHTML(item codexThreadItemView) string {
|
|
|
|
|
|
|
|
|
|
switch item.Type {
|
|
|
|
|
case "commandExecution":
|
|
|
|
|
if command := strings.TrimSpace(item.Command); command != "" {
|
|
|
|
|
parts = append(parts, "<b>Command</b>", CodeBlockHTML("bash", command))
|
|
|
|
|
}
|
|
|
|
|
appendField("CWD", item.CWD)
|
|
|
|
|
appendInt("Exit code", item.ExitCode)
|
|
|
|
|
appendInt64("Duration ms", item.DurationMs)
|
|
|
|
|
if item.AggregatedOutput != nil && strings.TrimSpace(*item.AggregatedOutput) != "" {
|
|
|
|
|
parts = append(parts, "<b>Output</b>", CodeBlockHTML("text", *item.AggregatedOutput))
|
|
|
|
|
}
|
|
|
|
|
parts = append(parts, commandExecutionDetailsHTML(item, ""))
|
|
|
|
|
case "fileChange":
|
|
|
|
|
appendField("Status", item.Status)
|
|
|
|
|
for _, change := range item.Changes {
|
|
|
|
|
@@ -1392,7 +1467,7 @@ func renderArgumentFieldHTML(key string, value any) string {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if complex || shouldUseCodeBlock(key, text) {
|
|
|
|
|
return "<b>" + EscapeHTML(label) + "</b>\n" + CodeBlockHTML(languageForArgument(key, text), text)
|
|
|
|
|
return "<b>" + EscapeHTML(label) + ":</b> " + CodeBlockHTML(languageForArgument(key, text), text)
|
|
|
|
|
}
|
|
|
|
|
return FieldHTML(label, text)
|
|
|
|
|
}
|
|
|
|
|
@@ -1540,7 +1615,10 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
|
|
|
|
|
}
|
|
|
|
|
if params.ThreadID != "" && !params.WillRetry {
|
|
|
|
|
if thread, err := b.store.GetThreadByCodexID(ctx, params.ThreadID); err == nil {
|
|
|
|
|
_ = b.store.SetActiveTurn(ctx, thread.TelegramUserID, "")
|
|
|
|
|
_ = b.store.ClearActiveTurn(ctx, thread.TelegramUserID, params.TurnID)
|
|
|
|
|
}
|
|
|
|
|
if !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
message := "Codex error"
|
|
|
|
|
if params.Error.Message != "" {
|
|
|
|
|
@@ -1561,48 +1639,55 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
|
|
|
|
|
case "item/started":
|
|
|
|
|
var params struct {
|
|
|
|
|
ThreadID string `json:"threadId"`
|
|
|
|
|
TurnID string `json:"turnId"`
|
|
|
|
|
Item json.RawMessage `json:"item"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if params.ThreadID == "" || !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
item, err := parseCodexThreadItem(params.Item)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if params.ThreadID != "" && item.Type == "agentMessage" && b.hasAssistantText(params.ThreadID) {
|
|
|
|
|
if item.Type == "agentMessage" && b.hasAssistantText(params.ThreadID) {
|
|
|
|
|
return b.flushAssistantMessage(ctx, params.ThreadID)
|
|
|
|
|
}
|
|
|
|
|
if params.ThreadID != "" {
|
|
|
|
|
if b.shouldSuppressPictureToolMessage(params.ThreadID, item) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return b.upsertToolMessage(ctx, params.ThreadID, item.ID, renderCodexItemStarted(item))
|
|
|
|
|
if b.shouldSuppressPictureToolMessage(params.ThreadID, item) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return b.upsertToolMessage(ctx, params.ThreadID, params.TurnID, item.ID, renderCodexItemStarted(item))
|
|
|
|
|
case "item/agentMessage/delta":
|
|
|
|
|
var params struct {
|
|
|
|
|
ThreadID string `json:"threadId"`
|
|
|
|
|
TurnID string `json:"turnId"`
|
|
|
|
|
Delta string `json:"delta"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if params.ThreadID != "" && params.Delta != "" {
|
|
|
|
|
if params.ThreadID != "" && params.Delta != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
|
|
|
|
|
return b.appendAssistantDelta(ctx, params.ThreadID, params.Delta)
|
|
|
|
|
}
|
|
|
|
|
case "item/completed":
|
|
|
|
|
var params struct {
|
|
|
|
|
ThreadID string `json:"threadId"`
|
|
|
|
|
TurnID string `json:"turnId"`
|
|
|
|
|
Item json.RawMessage `json:"item"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if params.ThreadID == "" || !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
item, err := parseCodexThreadItem(params.Item)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if params.ThreadID != "" && item.Type == "agentMessage" {
|
|
|
|
|
if item.Type == "agentMessage" {
|
|
|
|
|
if item.Text != "" && !b.hasAssistantText(params.ThreadID) {
|
|
|
|
|
if err := b.appendAssistantDelta(ctx, params.ThreadID, item.Text); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
@@ -1610,24 +1695,23 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
|
|
|
|
|
}
|
|
|
|
|
return b.flushAssistantMessage(ctx, params.ThreadID)
|
|
|
|
|
}
|
|
|
|
|
if params.ThreadID != "" {
|
|
|
|
|
if b.queuePictureImageOutput(params.ThreadID, item) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if err := b.upsertToolMessage(ctx, params.ThreadID, item.ID, renderCodexItemCompleted(item)); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return b.sendImageOutput(ctx, params.ThreadID, item)
|
|
|
|
|
if b.queuePictureImageOutput(params.ThreadID, item) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if err := b.upsertToolMessage(ctx, params.ThreadID, params.TurnID, item.ID, renderCodexItemCompleted(item)); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return b.sendImageOutput(ctx, params.ThreadID, item)
|
|
|
|
|
case "turn/diff/updated":
|
|
|
|
|
var params struct {
|
|
|
|
|
ThreadID string `json:"threadId"`
|
|
|
|
|
TurnID string `json:"turnId"`
|
|
|
|
|
Diff string `json:"diff"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if params.ThreadID != "" {
|
|
|
|
|
if params.ThreadID != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
b.diffs[params.ThreadID] = params.Diff
|
|
|
|
|
b.mu.Unlock()
|
|
|
|
|
@@ -1645,9 +1729,12 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
|
|
|
|
|
}
|
|
|
|
|
if params.ThreadID != "" {
|
|
|
|
|
if thread, err := b.store.GetThreadByCodexID(ctx, params.ThreadID); err == nil {
|
|
|
|
|
_ = b.store.SetActiveTurn(ctx, thread.TelegramUserID, "")
|
|
|
|
|
_ = b.store.ClearActiveTurn(ctx, thread.TelegramUserID, params.Turn.ID)
|
|
|
|
|
_ = b.store.TouchThread(ctx, params.ThreadID)
|
|
|
|
|
}
|
|
|
|
|
if !b.shouldHandleOutputEvent(params.ThreadID, params.Turn.ID) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return b.completeTurnOutput(ctx, params.ThreadID)
|
|
|
|
|
}
|
|
|
|
|
case "thread/name/updated":
|
|
|
|
|
@@ -1742,7 +1829,7 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event
|
|
|
|
|
if threadID == "" {
|
|
|
|
|
return errors.New("approval request missing threadId")
|
|
|
|
|
}
|
|
|
|
|
itemID := firstNonEmpty(params.ItemID, params.ApprovalID, params.CallID)
|
|
|
|
|
itemID := firstNonEmpty(params.ApprovalID, params.ItemID, params.CallID)
|
|
|
|
|
thread, err := b.store.GetThreadByCodexID(ctx, threadID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
@@ -1768,8 +1855,8 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
text := renderApprovalHTML(kind, event.Params, "")
|
|
|
|
|
markup := approvalMarkup(approval.ID)
|
|
|
|
|
msg, err := b.upsertApprovalMessage(ctx, thread.TelegramUserID, threadID, itemID, text, markup)
|
|
|
|
|
markup := approvalMarkupForPayload(approval.ID, event.Params)
|
|
|
|
|
msg, err := b.upsertApprovalMessage(ctx, thread.TelegramUserID, threadID, params.TurnID, itemID, text, markup)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
@@ -1785,8 +1872,9 @@ func firstNonEmpty(values ...string) string {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) newOutputState(chatID int64) *outputState {
|
|
|
|
|
func (b *Bot) newOutputState(chatID int64, turnID string) *outputState {
|
|
|
|
|
return &outputState{
|
|
|
|
|
turnID: turnID,
|
|
|
|
|
chatID: chatID,
|
|
|
|
|
tools: make(map[string]toolMessageState),
|
|
|
|
|
sentImages: make(map[string]bool),
|
|
|
|
|
@@ -1794,17 +1882,17 @@ func (b *Bot) newOutputState(chatID int64) *outputState {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) registerOutput(threadID string, chatID int64) {
|
|
|
|
|
func (b *Bot) registerOutput(threadID, turnID string, chatID int64) {
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
defer b.mu.Unlock()
|
|
|
|
|
if state := b.outputs[threadID]; state != nil && state.workingIndicatorOff != nil {
|
|
|
|
|
state.workingIndicatorOff()
|
|
|
|
|
}
|
|
|
|
|
b.outputs[threadID] = b.newOutputState(chatID)
|
|
|
|
|
b.outputs[threadID] = b.newOutputState(chatID, turnID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) registerPictureOutput(threadID string, chatID int64) {
|
|
|
|
|
b.registerOutput(threadID, chatID)
|
|
|
|
|
func (b *Bot) registerPictureOutput(threadID, turnID string, chatID int64) {
|
|
|
|
|
b.registerOutput(threadID, turnID, chatID)
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
defer b.mu.Unlock()
|
|
|
|
|
if state := b.outputs[threadID]; state != nil {
|
|
|
|
|
@@ -1812,6 +1900,43 @@ func (b *Bot) registerPictureOutput(threadID string, chatID int64) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) setOutputTurnID(threadID, turnID string) {
|
|
|
|
|
if strings.TrimSpace(turnID) == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
defer b.mu.Unlock()
|
|
|
|
|
if state := b.outputs[threadID]; state != nil {
|
|
|
|
|
state.turnID = turnID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) hasOutputTurn(threadID, turnID string) bool {
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
defer b.mu.Unlock()
|
|
|
|
|
state := b.outputs[threadID]
|
|
|
|
|
return state != nil && sameTurn(state.turnID, turnID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) shouldHandleOutputEvent(threadID, turnID string) bool {
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
defer b.mu.Unlock()
|
|
|
|
|
state := b.outputs[threadID]
|
|
|
|
|
if state == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if state.turnID == "" && strings.TrimSpace(turnID) != "" {
|
|
|
|
|
state.turnID = turnID
|
|
|
|
|
}
|
|
|
|
|
return sameTurn(state.turnID, turnID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sameTurn(ownedTurnID, eventTurnID string) bool {
|
|
|
|
|
ownedTurnID = strings.TrimSpace(ownedTurnID)
|
|
|
|
|
eventTurnID = strings.TrimSpace(eventTurnID)
|
|
|
|
|
return ownedTurnID == "" || eventTurnID == "" || ownedTurnID == eventTurnID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) clearOutput(threadID string) {
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
state := b.outputs[threadID]
|
|
|
|
|
@@ -1872,6 +1997,10 @@ func (b *Bot) hasAssistantText(threadID string) bool {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) failActiveOutputs(ctx context.Context, message string) {
|
|
|
|
|
if err := b.store.ClearActiveTurns(ctx); err != nil {
|
|
|
|
|
b.logger.Printf("clear active turns after Codex disconnect: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
threadIDs := make([]string, 0, len(b.outputs))
|
|
|
|
|
for threadID := range b.outputs {
|
|
|
|
|
@@ -1880,9 +2009,6 @@ func (b *Bot) failActiveOutputs(ctx context.Context, message string) {
|
|
|
|
|
b.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
for _, threadID := range threadIDs {
|
|
|
|
|
if thread, err := b.store.GetThreadByCodexID(ctx, threadID); err == nil {
|
|
|
|
|
_ = b.store.SetActiveTurn(ctx, thread.TelegramUserID, "")
|
|
|
|
|
}
|
|
|
|
|
if err := b.flushAssistantMessage(ctx, threadID); err != nil {
|
|
|
|
|
b.logger.Printf("flush failed output %s: %v", threadID, err)
|
|
|
|
|
}
|
|
|
|
|
@@ -2031,28 +2157,56 @@ func addEditedAtLine(htmlText, editedAt string) string {
|
|
|
|
|
if htmlText == "" || editedAt == "" {
|
|
|
|
|
return htmlText
|
|
|
|
|
}
|
|
|
|
|
line := EscapeHTML("Edited at: " + editedAt)
|
|
|
|
|
quoteIndex := strings.Index(htmlText, "<blockquote expandable>")
|
|
|
|
|
if quoteIndex < 0 {
|
|
|
|
|
return htmlText + "\n" + line
|
|
|
|
|
line := FieldHTML("Edited at", editedAt)
|
|
|
|
|
if strings.Contains(htmlText, line) {
|
|
|
|
|
return htmlText
|
|
|
|
|
}
|
|
|
|
|
summary := strings.TrimRight(htmlText[:quoteIndex], "\n")
|
|
|
|
|
details := strings.TrimLeft(htmlText[quoteIndex:], "\n")
|
|
|
|
|
if summary == "" {
|
|
|
|
|
return line + "\n" + details
|
|
|
|
|
for _, marker := range []string{"\n\nCodex requests command approval", "\n\nCodex requests file change approval", "\n\nCodex requests permission approval", "\n\nCodex approval requested"} {
|
|
|
|
|
if before, after, ok := strings.Cut(htmlText, marker); ok {
|
|
|
|
|
return addEditedAtToToolHTML(before, line) + marker + after
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return summary + "\n" + line + "\n" + details
|
|
|
|
|
return addEditedAtToToolHTML(htmlText, line)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func addEditedAtToToolHTML(htmlText, line string) string {
|
|
|
|
|
const open = "<blockquote expandable>"
|
|
|
|
|
const close = "</blockquote>"
|
|
|
|
|
searchFrom := 0
|
|
|
|
|
for {
|
|
|
|
|
start := strings.Index(htmlText[searchFrom:], open)
|
|
|
|
|
if start < 0 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
start += searchFrom
|
|
|
|
|
contentStart := start + len(open)
|
|
|
|
|
end := strings.Index(htmlText[contentStart:], close)
|
|
|
|
|
if end < 0 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
end += contentStart
|
|
|
|
|
content := htmlText[contentStart:end]
|
|
|
|
|
if strings.Contains(content, "<b>Exit code:</b>") || strings.Contains(content, "<b>Duration ms:</b>") {
|
|
|
|
|
insert := strings.TrimRight(content, "\n") + "\n" + line
|
|
|
|
|
return htmlText[:contentStart] + insert + htmlText[end:]
|
|
|
|
|
}
|
|
|
|
|
searchFrom = end + len(close)
|
|
|
|
|
}
|
|
|
|
|
return htmlText + "\n" + ExpandableQuoteRawHTML(line)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func editedAtTimestamp() string {
|
|
|
|
|
return time.Now().UTC().Format("2006-01-02 15:04:05 MST")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) upsertToolMessage(ctx context.Context, threadID, itemID, htmlText string) error {
|
|
|
|
|
func (b *Bot) upsertToolMessage(ctx context.Context, threadID, turnID, itemID, htmlText string) error {
|
|
|
|
|
htmlText = strings.TrimSpace(htmlText)
|
|
|
|
|
if htmlText == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if !b.hasOutputTurn(threadID, turnID) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if itemID == "" {
|
|
|
|
|
return b.sendOutputHTMLBlock(ctx, threadID, htmlText)
|
|
|
|
|
}
|
|
|
|
|
@@ -2107,21 +2261,22 @@ func (b *Bot) upsertToolMessage(ctx context.Context, threadID, itemID, htmlText
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) upsertApprovalMessage(ctx context.Context, chatID int64, threadID, itemID, approvalHTML string, markup *InlineKeyboardMarkup) (Message, error) {
|
|
|
|
|
func (b *Bot) upsertApprovalMessage(ctx context.Context, chatID int64, threadID, turnID, itemID, approvalHTML string, markup *InlineKeyboardMarkup) (Message, error) {
|
|
|
|
|
approvalHTML = strings.TrimSpace(approvalHTML)
|
|
|
|
|
if approvalHTML == "" {
|
|
|
|
|
return Message{}, errors.New("approval message is empty")
|
|
|
|
|
}
|
|
|
|
|
if threadID == "" || itemID == "" {
|
|
|
|
|
if threadID == "" || itemID == "" || !b.hasOutputTurn(threadID, turnID) {
|
|
|
|
|
return b.tg.SendMessage(ctx, chatID, approvalHTML, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup})
|
|
|
|
|
}
|
|
|
|
|
if err := b.flushAssistantMessage(ctx, threadID); err != nil {
|
|
|
|
|
return Message{}, err
|
|
|
|
|
}
|
|
|
|
|
chatID, err := b.outputChatID(ctx, threadID)
|
|
|
|
|
trackedChatID, err := b.outputChatID(ctx, threadID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Message{}, err
|
|
|
|
|
return b.tg.SendMessage(ctx, chatID, approvalHTML, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup})
|
|
|
|
|
}
|
|
|
|
|
chatID = trackedChatID
|
|
|
|
|
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
state := b.outputs[threadID]
|
|
|
|
|
@@ -2510,13 +2665,7 @@ func (b *Bot) outputChatID(ctx context.Context, threadID string) (int64, error)
|
|
|
|
|
return chatID, nil
|
|
|
|
|
}
|
|
|
|
|
b.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
thread, err := b.store.GetThreadByCodexID(ctx, threadID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
b.registerOutput(threadID, thread.TelegramUserID)
|
|
|
|
|
return thread.TelegramUserID, nil
|
|
|
|
|
return 0, sql.ErrNoRows
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Bot) markOutputSent(threadID string) {
|
|
|
|
|
@@ -2580,11 +2729,11 @@ func parseCommand(text string) (string, []string, bool) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func resumeThreadListText(threads []store.Thread, page int) string {
|
|
|
|
|
lines := []string{fmt.Sprintf("Resume a thread (page %d):", page+1), ""}
|
|
|
|
|
lines := []string{fmt.Sprintf("Threads (page %d):", page+1), ""}
|
|
|
|
|
for _, thread := range threads {
|
|
|
|
|
lines = append(lines, fmt.Sprintf("Thread ID %d: %s", thread.ID, threadDisplayTitle(thread)))
|
|
|
|
|
}
|
|
|
|
|
lines = append(lines, "", "Choose a button below, or use /resume THREAD_ID directly.")
|
|
|
|
|
lines = append(lines, "", "Choose a button below, or use /thread THREAD_ID directly.")
|
|
|
|
|
return strings.Join(lines, "\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -2743,23 +2892,158 @@ func editReplyMarkup(markup *InlineKeyboardMarkup) *InlineKeyboardMarkup {
|
|
|
|
|
return clearInlineKeyboardMarkup()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type approvalDecisionOption struct {
|
|
|
|
|
Key string
|
|
|
|
|
Label string
|
|
|
|
|
Approves bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func approvalMarkup(id int64) *InlineKeyboardMarkup {
|
|
|
|
|
return &InlineKeyboardMarkup{InlineKeyboard: [][]InlineKeyboardButton{
|
|
|
|
|
{
|
|
|
|
|
{Text: "Approve", CallbackData: ApprovalCallbackData(id, "accept")},
|
|
|
|
|
{Text: "Deny", CallbackData: ApprovalCallbackData(id, "decline")},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
{Text: "Details", CallbackData: ApprovalCallbackData(id, "details")},
|
|
|
|
|
{Text: "Cancel", CallbackData: ApprovalCallbackData(id, "cancel")},
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
return approvalMarkupForOptions(id, nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func approvalMarkupForPayload(id int64, raw json.RawMessage) *InlineKeyboardMarkup {
|
|
|
|
|
return approvalMarkupForOptions(id, approvalDecisionOptions(raw))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func approvalDecisionOptions(raw json.RawMessage) []approvalDecisionOption {
|
|
|
|
|
var params struct {
|
|
|
|
|
AvailableDecisions []json.RawMessage `json:"availableDecisions"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(raw, ¶ms); err != nil || params.AvailableDecisions == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
seen := map[string]bool{}
|
|
|
|
|
var options []approvalDecisionOption
|
|
|
|
|
networkIndex := 0
|
|
|
|
|
for _, rawDecision := range params.AvailableDecisions {
|
|
|
|
|
if option, ok := stringApprovalDecisionOption(rawDecision); ok {
|
|
|
|
|
if !seen[option.Key] {
|
|
|
|
|
options = append(options, option)
|
|
|
|
|
seen[option.Key] = true
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if option, ok := structuredApprovalDecisionOption(rawDecision, networkIndex); ok {
|
|
|
|
|
if strings.HasPrefix(option.Key, "networkPolicy") {
|
|
|
|
|
networkIndex++
|
|
|
|
|
}
|
|
|
|
|
if !seen[option.Key] {
|
|
|
|
|
options = append(options, option)
|
|
|
|
|
seen[option.Key] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return options
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func stringApprovalDecisionOption(raw json.RawMessage) (approvalDecisionOption, bool) {
|
|
|
|
|
var decision string
|
|
|
|
|
if err := json.Unmarshal(raw, &decision); err != nil {
|
|
|
|
|
return approvalDecisionOption{}, false
|
|
|
|
|
}
|
|
|
|
|
switch decision {
|
|
|
|
|
case "accept":
|
|
|
|
|
return approvalDecisionOption{Key: decision, Label: "Approve", Approves: true}, true
|
|
|
|
|
case "acceptForSession":
|
|
|
|
|
return approvalDecisionOption{Key: decision, Label: "Approve session", Approves: true}, true
|
|
|
|
|
case "decline":
|
|
|
|
|
return approvalDecisionOption{Key: decision, Label: "Deny"}, true
|
|
|
|
|
case "cancel":
|
|
|
|
|
return approvalDecisionOption{Key: decision, Label: "Cancel"}, true
|
|
|
|
|
default:
|
|
|
|
|
return approvalDecisionOption{}, false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func structuredApprovalDecisionOption(raw json.RawMessage, networkIndex int) (approvalDecisionOption, bool) {
|
|
|
|
|
var object map[string]json.RawMessage
|
|
|
|
|
if err := json.Unmarshal(raw, &object); err != nil {
|
|
|
|
|
return approvalDecisionOption{}, false
|
|
|
|
|
}
|
|
|
|
|
if _, ok := object["acceptWithExecpolicyAmendment"]; ok {
|
|
|
|
|
return approvalDecisionOption{Key: "acceptWithExecpolicyAmendment", Label: "Approve rule", Approves: true}, true
|
|
|
|
|
}
|
|
|
|
|
if rawNetwork, ok := object["applyNetworkPolicyAmendment"]; ok {
|
|
|
|
|
label := "Apply network rule"
|
|
|
|
|
approves := true
|
|
|
|
|
var payload struct {
|
|
|
|
|
NetworkPolicyAmendment struct {
|
|
|
|
|
Action string `json:"action"`
|
|
|
|
|
Host string `json:"host"`
|
|
|
|
|
} `json:"network_policy_amendment"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(rawNetwork, &payload); err == nil {
|
|
|
|
|
host := strings.TrimSpace(payload.NetworkPolicyAmendment.Host)
|
|
|
|
|
switch payload.NetworkPolicyAmendment.Action {
|
|
|
|
|
case "allow":
|
|
|
|
|
label = "Allow network"
|
|
|
|
|
if host != "" {
|
|
|
|
|
label = "Allow " + host
|
|
|
|
|
}
|
|
|
|
|
case "deny":
|
|
|
|
|
label = "Deny network"
|
|
|
|
|
approves = false
|
|
|
|
|
if host != "" {
|
|
|
|
|
label = "Deny " + host
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return approvalDecisionOption{Key: fmt.Sprintf("networkPolicy%d", networkIndex), Label: label, Approves: approves}, true
|
|
|
|
|
}
|
|
|
|
|
return approvalDecisionOption{}, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func approvalMarkupForOptions(id int64, options []approvalDecisionOption) *InlineKeyboardMarkup {
|
|
|
|
|
if len(options) == 0 {
|
|
|
|
|
options = []approvalDecisionOption{
|
|
|
|
|
{Key: "accept", Label: "Approve", Approves: true},
|
|
|
|
|
{Key: "decline", Label: "Deny"},
|
|
|
|
|
{Key: "cancel", Label: "Cancel"},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
var approveRow []InlineKeyboardButton
|
|
|
|
|
var denyRow []InlineKeyboardButton
|
|
|
|
|
for _, option := range options {
|
|
|
|
|
if option.Key == "" || option.Label == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
button := InlineKeyboardButton{Text: truncateButtonLabel(option.Label), CallbackData: ApprovalCallbackData(id, option.Key)}
|
|
|
|
|
if option.Approves {
|
|
|
|
|
approveRow = append(approveRow, button)
|
|
|
|
|
} else {
|
|
|
|
|
denyRow = append(denyRow, button)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
keyboard := make([][]InlineKeyboardButton, 0, 3)
|
|
|
|
|
if len(approveRow) > 0 {
|
|
|
|
|
keyboard = append(keyboard, approveRow)
|
|
|
|
|
}
|
|
|
|
|
if len(denyRow) > 0 {
|
|
|
|
|
keyboard = append(keyboard, denyRow)
|
|
|
|
|
}
|
|
|
|
|
keyboard = append(keyboard, []InlineKeyboardButton{{Text: "Details", CallbackData: ApprovalCallbackData(id, "details")}})
|
|
|
|
|
return &InlineKeyboardMarkup{InlineKeyboard: keyboard}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func truncateButtonLabel(label string) string {
|
|
|
|
|
const maxRunes = 48
|
|
|
|
|
runes := []rune(label)
|
|
|
|
|
if len(runes) <= maxRunes {
|
|
|
|
|
return label
|
|
|
|
|
}
|
|
|
|
|
return string(runes[:maxRunes-3]) + "..."
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func approvalResponse(approval store.PendingApproval, decision string) any {
|
|
|
|
|
if isLegacyApprovalKind(approval.Kind) {
|
|
|
|
|
return map[string]any{"decision": legacyApprovalDecision(decision)}
|
|
|
|
|
}
|
|
|
|
|
if approval.Kind == "item/commandExecution/requestApproval" {
|
|
|
|
|
if responseDecision, ok := commandApprovalResponseDecision(json.RawMessage(approval.PayloadJSON), decision); ok {
|
|
|
|
|
return map[string]any{"decision": responseDecision}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if approval.Kind != "item/permissions/requestApproval" {
|
|
|
|
|
return map[string]any{"decision": decision}
|
|
|
|
|
}
|
|
|
|
|
@@ -2784,6 +3068,77 @@ func approvalResponse(approval store.PendingApproval, decision string) any {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func commandApprovalResponseDecision(raw json.RawMessage, decision string) (any, bool) {
|
|
|
|
|
switch decision {
|
|
|
|
|
case "accept", "acceptForSession", "decline", "cancel":
|
|
|
|
|
return decision, true
|
|
|
|
|
case "acceptWithExecpolicyAmendment":
|
|
|
|
|
if value, ok := firstStructuredApprovalDecision(raw, "acceptWithExecpolicyAmendment", 0); ok {
|
|
|
|
|
return value, true
|
|
|
|
|
}
|
|
|
|
|
if value, ok := fallbackExecpolicyDecision(raw); ok {
|
|
|
|
|
return value, true
|
|
|
|
|
}
|
|
|
|
|
case "details":
|
|
|
|
|
return nil, false
|
|
|
|
|
default:
|
|
|
|
|
if strings.HasPrefix(decision, "networkPolicy") {
|
|
|
|
|
index, err := strconv.Atoi(strings.TrimPrefix(decision, "networkPolicy"))
|
|
|
|
|
if err == nil {
|
|
|
|
|
return firstStructuredApprovalDecision(raw, "applyNetworkPolicyAmendment", index)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func firstStructuredApprovalDecision(raw json.RawMessage, key string, index int) (any, bool) {
|
|
|
|
|
if index < 0 {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
var params struct {
|
|
|
|
|
AvailableDecisions []json.RawMessage `json:"availableDecisions"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
seen := 0
|
|
|
|
|
for _, rawDecision := range params.AvailableDecisions {
|
|
|
|
|
var object map[string]json.RawMessage
|
|
|
|
|
if err := json.Unmarshal(rawDecision, &object); err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
payload, ok := object[key]
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if seen != index {
|
|
|
|
|
seen++
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
var value map[string]any
|
|
|
|
|
if err := json.Unmarshal(payload, &value); err != nil {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return map[string]any{key: value}, true
|
|
|
|
|
}
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fallbackExecpolicyDecision(raw json.RawMessage) (any, bool) {
|
|
|
|
|
var params struct {
|
|
|
|
|
ProposedExecpolicyAmendment []string `json:"proposedExecpolicyAmendment"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(raw, ¶ms); err != nil || len(params.ProposedExecpolicyAmendment) == 0 {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return map[string]any{
|
|
|
|
|
"acceptWithExecpolicyAmendment": map[string]any{
|
|
|
|
|
"execpolicy_amendment": params.ProposedExecpolicyAmendment,
|
|
|
|
|
},
|
|
|
|
|
}, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isLegacyApprovalKind(kind string) bool {
|
|
|
|
|
switch kind {
|
|
|
|
|
case "execCommandApproval", "applyPatchApproval":
|
|
|
|
|
@@ -2827,12 +3182,12 @@ func renderApprovalHTML(kind string, raw json.RawMessage, status string) string
|
|
|
|
|
lines = append(lines, "", reason)
|
|
|
|
|
}
|
|
|
|
|
summary := strings.Join(lines, "\n")
|
|
|
|
|
details := renderApprovalDetailsHTML(kind, raw)
|
|
|
|
|
sections := renderApprovalDetailSectionsHTML(kind, raw)
|
|
|
|
|
limit := TelegramHTMLMessageLimit
|
|
|
|
|
if status != "" {
|
|
|
|
|
limit -= len([]rune(status)) + 1
|
|
|
|
|
}
|
|
|
|
|
text := SummaryDetailsRawHTMLLimited(summary, details, limit)
|
|
|
|
|
text := SummaryRawHTMLSectionsLimited(summary, sections, limit)
|
|
|
|
|
if status != "" {
|
|
|
|
|
text += "\n" + EscapeHTML(status)
|
|
|
|
|
}
|
|
|
|
|
@@ -2840,23 +3195,32 @@ func renderApprovalHTML(kind string, raw json.RawMessage, status string) string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func renderApprovalDetailsHTML(kind string, raw json.RawMessage) string {
|
|
|
|
|
return strings.Join(renderApprovalDetailSectionsHTML(kind, raw), "\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func renderApprovalDetailSectionsHTML(kind string, raw json.RawMessage) []string {
|
|
|
|
|
var params map[string]any
|
|
|
|
|
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
|
|
|
|
return CodeBlockHTML("json", string(raw))
|
|
|
|
|
return []string{CodeBlockHTML("json", string(raw))}
|
|
|
|
|
}
|
|
|
|
|
parts := renderSelectedArgumentDetailsHTML(params, []string{"command", "cwd", "grantRoot", "permissions", "fileChanges", "parsedCmd", "reason"})
|
|
|
|
|
parts := renderSelectedArgumentDetailsHTML(params, []string{"cwd", "command", "additionalPermissions", "networkApprovalContext", "proposedExecpolicyAmendment", "proposedNetworkPolicyAmendments", "availableDecisions", "grantRoot", "permissions", "fileChanges", "parsedCmd", "reason"})
|
|
|
|
|
if len(parts) == 0 {
|
|
|
|
|
return CodeBlockHTML("json", prettyJSON(raw))
|
|
|
|
|
return []string{CodeBlockHTML("json", prettyJSON(raw))}
|
|
|
|
|
}
|
|
|
|
|
return strings.Join(nonEmptyHTML(parts), "\n")
|
|
|
|
|
return nonEmptyHTML(parts)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func approvalStatusLine(decision string) string {
|
|
|
|
|
if strings.HasPrefix(decision, "networkPolicy") {
|
|
|
|
|
return "Applied network rule."
|
|
|
|
|
}
|
|
|
|
|
switch decision {
|
|
|
|
|
case "accept":
|
|
|
|
|
return "Approved."
|
|
|
|
|
case "acceptForSession":
|
|
|
|
|
return "Approved for this session."
|
|
|
|
|
case "acceptWithExecpolicyAmendment":
|
|
|
|
|
return "Approved and saved command rule."
|
|
|
|
|
case "decline":
|
|
|
|
|
return "Disapproved."
|
|
|
|
|
case "cancel":
|
|
|
|
|
|