Compare commits
4 Commits
ab5cc4fbfe
...
34e909f9cf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e909f9cf | ||
|
|
c00ffb42f2 | ||
|
|
2b0da9f508 | ||
|
|
e9dd840111 |
4
PLAN.md
4
PLAN.md
@@ -52,7 +52,7 @@ Before implementation, carefully read the official docs and check against the gu
|
||||
- Telegram UX:
|
||||
- One-to-one chats only; reject groups, supergroups, and channels.
|
||||
- Allowlisted Telegram user IDs only.
|
||||
- Commands: `/start`, `/help`, `/new`, `/threads`, `/resume`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/diff`.
|
||||
- Commands: `/start`, `/help`, `/new`, `/thread`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/diff`.
|
||||
- Plain text continues the active Codex thread, creating one if needed.
|
||||
- Send assistant messages and rendered tool/status blocks as separate Telegram messages; chunk only when a single message exceeds Telegram limits.
|
||||
- Send long output, logs, and diffs as chunked messages.
|
||||
@@ -77,7 +77,7 @@ Any interactive related test that requires user action should be done properly -
|
||||
- Integration tests:
|
||||
- Use a low/mini model in codex for testing.
|
||||
- Verify initialize, thread start, turn start, streamed output, approval, and cancellation.
|
||||
- Verify `/start`, `/new`, `/threads`, `/resume`, `/workspace`, `/cancel`, image input, and document staging.
|
||||
- Verify `/start`, `/new`, `/thread`, `/workspace`, `/cancel`, image input, and document staging.
|
||||
- Verify non-allowlisted users are rejected and logged.
|
||||
|
||||
- Manual acceptance:
|
||||
|
||||
@@ -38,7 +38,7 @@ Docker Compose runs only the Go Telegram bot. Codex runs on the host through `co
|
||||
|
||||
The bot accepts one-to-one chats from allowlisted Telegram user IDs only. It rejects group, supergroup, and channel updates in code.
|
||||
|
||||
Supported commands: `/start`, `/help`, `/new`, `/threads`, `/resume`, `/rename`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/pic`, `/diff`. `/model` lists available Codex models as inline buttons, then shows reasoning-effort buttons for the selected model.
|
||||
Supported commands: `/start`, `/help`, `/new`, `/thread`, `/rename`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/pic`, `/diff`. `/model` lists available Codex models as inline buttons, then shows reasoning-effort buttons for the selected model.
|
||||
|
||||
Plain text continues the active Codex thread and creates one if needed. `/pic PROMPT` starts a dedicated Codex image-generation turn and sends generated images back as Telegram photos. Telegram images are staged under `HOST_UPLOAD_DIR` and sent as `localImage` inputs. Other uploaded documents are staged and passed to Codex as host-visible file paths.
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ func main() {
|
||||
|
||||
tg := telegram.NewClient(cfg.TelegramToken)
|
||||
codex := codexapp.New(cfg.CodexSocketPath, cfg.AppVersion)
|
||||
codex.SetLogger(logger)
|
||||
defer codex.Close()
|
||||
|
||||
bot := telegram.NewBot(tg, st, codex, cfg.UploadDir, cfg.CodexHome, cfg.CodexStateDB, cfg.DefaultModel, cfg.DefaultSandbox, cfg.PollTimeout, logger)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -26,6 +27,7 @@ type Client struct {
|
||||
pending map[string]chan rpcResult
|
||||
events chan Event
|
||||
connected bool
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
@@ -151,6 +153,12 @@ func New(socketPath, version string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetLogger(logger *log.Logger) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.logger = logger
|
||||
}
|
||||
|
||||
func (c *Client) Events() <-chan Event {
|
||||
return c.events
|
||||
}
|
||||
@@ -240,10 +248,11 @@ func (c *Client) StartThread(ctx context.Context, cwd, model, sandbox string) (T
|
||||
return Thread{}, err
|
||||
}
|
||||
params := map[string]any{
|
||||
"cwd": cwd,
|
||||
"approvalPolicy": "on-request",
|
||||
"sandbox": threadSandbox(sandbox),
|
||||
"serviceName": "codex_telegram_bot",
|
||||
"cwd": cwd,
|
||||
"approvalPolicy": "on-request",
|
||||
"approvalsReviewer": "user",
|
||||
"sandbox": threadSandbox(sandbox),
|
||||
"serviceName": "codex_telegram_bot",
|
||||
}
|
||||
if model != "" {
|
||||
params["model"] = model
|
||||
@@ -327,9 +336,10 @@ func (c *Client) StartTurn(ctx context.Context, threadID, cwd, model, reasoningE
|
||||
return Turn{}, err
|
||||
}
|
||||
params := map[string]any{
|
||||
"threadId": threadID,
|
||||
"input": input,
|
||||
"approvalPolicy": "on-request",
|
||||
"threadId": threadID,
|
||||
"input": input,
|
||||
"approvalPolicy": "on-request",
|
||||
"approvalsReviewer": "user",
|
||||
}
|
||||
if strings.TrimSpace(cwd) != "" {
|
||||
params["cwd"] = cwd
|
||||
@@ -463,6 +473,7 @@ func (c *Client) readLoop(conn *websocket.Conn) {
|
||||
return
|
||||
}
|
||||
if env.Method != "" && hasID {
|
||||
c.logProtocolEvent("server request", env.Method, id.Key(), env.Params)
|
||||
c.events <- Event{
|
||||
ID: &id,
|
||||
Method: env.Method,
|
||||
@@ -476,6 +487,9 @@ func (c *Client) readLoop(conn *websocket.Conn) {
|
||||
continue
|
||||
}
|
||||
if env.Method != "" {
|
||||
if shouldLogNotification(env.Method) {
|
||||
c.logProtocolEvent("notification", env.Method, "", env.Params)
|
||||
}
|
||||
c.events <- Event{
|
||||
Method: env.Method,
|
||||
Params: env.Params,
|
||||
@@ -484,6 +498,29 @@ func (c *Client) readLoop(conn *websocket.Conn) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) logProtocolEvent(kind, method, id string, params json.RawMessage) {
|
||||
c.mu.Lock()
|
||||
logger := c.logger
|
||||
c.mu.Unlock()
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
if id != "" {
|
||||
logger.Printf("codex %s: method=%s id=%s params_bytes=%d", kind, method, id, len(params))
|
||||
return
|
||||
}
|
||||
logger.Printf("codex %s: method=%s params_bytes=%d", kind, method, len(params))
|
||||
}
|
||||
|
||||
func shouldLogNotification(method string) bool {
|
||||
switch method {
|
||||
case "item/guardianApprovalReview/started", "item/guardianApprovalReview/completed", "guardianWarning", "serverRequest/resolved":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) completeCall(id RequestID, result json.RawMessage, rpcErr *RPCError) {
|
||||
c.mu.Lock()
|
||||
ch := c.pending[id.Key()]
|
||||
|
||||
@@ -77,6 +77,12 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
|
||||
serverDone <- unexpectedMessage("thread/start", start["method"])
|
||||
return
|
||||
}
|
||||
startParams := start["params"].(map[string]any)
|
||||
if startParams["approvalsReviewer"] != "user" || startParams["approvalPolicy"] != "on-request" {
|
||||
payload, _ := json.Marshal(startParams)
|
||||
serverDone <- unexpectedMessage("thread/start approval params", string(payload))
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(map[string]any{
|
||||
"id": start["id"],
|
||||
"result": map[string]any{
|
||||
@@ -88,6 +94,26 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
var turnStart map[string]any
|
||||
if err := conn.ReadJSON(&turnStart); err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
if turnStart["method"] != "turn/start" {
|
||||
serverDone <- unexpectedMessage("turn/start", turnStart["method"])
|
||||
return
|
||||
}
|
||||
turnParams := turnStart["params"].(map[string]any)
|
||||
if turnParams["approvalsReviewer"] != "user" || turnParams["approvalPolicy"] != "on-request" {
|
||||
payload, _ := json.Marshal(turnParams)
|
||||
serverDone <- unexpectedMessage("turn/start approval params", string(payload))
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(map[string]any{"id": turnStart["id"], "result": map[string]any{"turn": map[string]any{"id": "turn_1", "status": "running"}}}); err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
|
||||
var readThread map[string]any
|
||||
if err := conn.ReadJSON(&readThread); err != nil {
|
||||
serverDone <- err
|
||||
@@ -174,6 +200,14 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
|
||||
if thread.ID != "thr_1" || thread.CWD != projectCWD {
|
||||
t.Fatalf("unexpected thread: %+v", thread)
|
||||
}
|
||||
turn, err := client.StartTurn(ctx, "thr_1", projectCWD, "", "", "workspace-write", []InputItem{{Type: "text", Text: "hello"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if turn.ID != "turn_1" {
|
||||
t.Fatalf("unexpected turn: %+v", turn)
|
||||
}
|
||||
|
||||
readThread, err := client.ReadThread(ctx, "thr_1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -44,6 +44,12 @@ type Session struct {
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type ActiveTurn struct {
|
||||
TelegramUserID int64
|
||||
CodexThreadID string
|
||||
TurnID string
|
||||
}
|
||||
|
||||
type Thread struct {
|
||||
ID int64
|
||||
TelegramUserID int64
|
||||
@@ -338,11 +344,42 @@ func (s *Store) SetActiveTurn(ctx context.Context, telegramUserID int64, turnID
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ClearActiveTurn(ctx context.Context, telegramUserID int64, turnID string) error {
|
||||
if strings.TrimSpace(turnID) == "" {
|
||||
return s.SetActiveTurn(ctx, telegramUserID, "")
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, "UPDATE sessions SET active_turn_id = '', updated_at = datetime('now') WHERE telegram_user_id = ? AND active_turn_id = ?", telegramUserID, turnID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ClearActiveTurns(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, "UPDATE sessions SET active_turn_id = '', updated_at = datetime('now') WHERE active_turn_id <> ''")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListActiveTurns(ctx context.Context) ([]ActiveTurn, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT s.telegram_user_id, t.codex_thread_id, s.active_turn_id
|
||||
FROM sessions s
|
||||
JOIN threads t ON t.id = s.active_thread_id AND t.telegram_user_id = s.telegram_user_id
|
||||
WHERE s.active_turn_id <> ''
|
||||
ORDER BY s.updated_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var turns []ActiveTurn
|
||||
for rows.Next() {
|
||||
var turn ActiveTurn
|
||||
if err := rows.Scan(&turn.TelegramUserID, &turn.CodexThreadID, &turn.TurnID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
turns = append(turns, turn)
|
||||
}
|
||||
return turns, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateThread(ctx context.Context, telegramUserID int64, codexThreadID string, workspaceID int64, title string) (Thread, error) {
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO threads (telegram_user_id, codex_thread_id, workspace_id, title)
|
||||
@@ -459,9 +496,41 @@ INSERT INTO pending_approvals (
|
||||
message_chat_id, message_id, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
|
||||
ON CONFLICT(telegram_user_id, codex_request_id) DO UPDATE SET
|
||||
codex_thread_id = excluded.codex_thread_id,
|
||||
turn_id = excluded.turn_id,
|
||||
item_id = excluded.item_id,
|
||||
kind = excluded.kind,
|
||||
payload_json = excluded.payload_json,
|
||||
message_chat_id = CASE WHEN pending_approvals.status = 'pending' THEN excluded.message_chat_id ELSE pending_approvals.message_chat_id END,
|
||||
message_id = CASE WHEN pending_approvals.status = 'pending' THEN excluded.message_id ELSE pending_approvals.message_id END`,
|
||||
message_chat_id = CASE
|
||||
WHEN pending_approvals.codex_thread_id = excluded.codex_thread_id
|
||||
AND pending_approvals.turn_id = excluded.turn_id
|
||||
AND pending_approvals.item_id = excluded.item_id
|
||||
THEN pending_approvals.message_chat_id
|
||||
ELSE excluded.message_chat_id
|
||||
END,
|
||||
message_id = CASE
|
||||
WHEN pending_approvals.codex_thread_id = excluded.codex_thread_id
|
||||
AND pending_approvals.turn_id = excluded.turn_id
|
||||
AND pending_approvals.item_id = excluded.item_id
|
||||
THEN pending_approvals.message_id
|
||||
ELSE excluded.message_id
|
||||
END,
|
||||
status = CASE
|
||||
WHEN pending_approvals.status <> 'pending'
|
||||
AND pending_approvals.codex_thread_id = excluded.codex_thread_id
|
||||
AND pending_approvals.turn_id = excluded.turn_id
|
||||
AND pending_approvals.item_id = excluded.item_id
|
||||
THEN pending_approvals.status
|
||||
ELSE 'pending'
|
||||
END,
|
||||
resolved_at = CASE
|
||||
WHEN pending_approvals.status <> 'pending'
|
||||
AND pending_approvals.codex_thread_id = excluded.codex_thread_id
|
||||
AND pending_approvals.turn_id = excluded.turn_id
|
||||
AND pending_approvals.item_id = excluded.item_id
|
||||
THEN pending_approvals.resolved_at
|
||||
ELSE ''
|
||||
END`,
|
||||
approval.TelegramUserID, approval.CodexRequestID, approval.CodexThreadID, approval.TurnID,
|
||||
approval.ItemID, approval.Kind, approval.PayloadJSON, approval.MessageChatID, approval.MessageID)
|
||||
if err != nil {
|
||||
|
||||
@@ -72,9 +72,33 @@ func TestStoreUsersWorkspacesSessions(t *testing.T) {
|
||||
if session.SettingsChatID != 1001 || session.SettingsMessageID != 2002 {
|
||||
t.Fatalf("settings message not saved: %+v", session)
|
||||
}
|
||||
thread, err := st.CreateThread(ctx, 42, "codex-thread-123", ws.ID, "test thread")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetActiveThread(ctx, 42, thread.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetActiveTurn(ctx, 42, "turn-123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
turns, err := st.ListActiveTurns(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(turns) != 1 || turns[0].TelegramUserID != 42 || turns[0].CodexThreadID != "codex-thread-123" || turns[0].TurnID != "turn-123" {
|
||||
t.Fatalf("active turns not listed: %+v", turns)
|
||||
}
|
||||
if err := st.ClearActiveTurn(ctx, 42, "other-turn"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err = st.GetSession(ctx, 42)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.ActiveTurnID != "turn-123" {
|
||||
t.Fatalf("wrong turn cleared: %+v", session)
|
||||
}
|
||||
if err := st.ClearActiveTurns(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -243,6 +267,62 @@ func TestUpsertPendingApprovalDoesNotReopenResolved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertPendingApprovalReopensReusedRequestIDForNewContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, err := Open(ctx, filepath.Join(t.TempDir(), "bot.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
first, err := st.UpsertPendingApproval(ctx, PendingApproval{
|
||||
TelegramUserID: 42,
|
||||
CodexRequestID: "i:0",
|
||||
CodexThreadID: "thread-1",
|
||||
TurnID: "turn-1",
|
||||
ItemID: "item-1",
|
||||
Kind: "item/commandExecution/requestApproval",
|
||||
PayloadJSON: `{"command":"go test ./..."}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpdatePendingApprovalMessage(ctx, first.ID, 1001, 2002); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ResolvePendingApproval(ctx, 42, first.ID, "accept"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, err := st.UpsertPendingApproval(ctx, PendingApproval{
|
||||
TelegramUserID: 42,
|
||||
CodexRequestID: "i:0",
|
||||
CodexThreadID: "thread-2",
|
||||
TurnID: "turn-2",
|
||||
ItemID: "item-2",
|
||||
Kind: "item/commandExecution/requestApproval",
|
||||
PayloadJSON: `{"command":"git push"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("reused request id row = %d, want %d", second.ID, first.ID)
|
||||
}
|
||||
if second.Status != "pending" {
|
||||
t.Fatalf("reused request status = %q, want pending", second.Status)
|
||||
}
|
||||
if second.CodexThreadID != "thread-2" || second.TurnID != "turn-2" || second.ItemID != "item-2" {
|
||||
t.Fatalf("reused request context = thread %q turn %q item %q", second.CodexThreadID, second.TurnID, second.ItemID)
|
||||
}
|
||||
if second.MessageChatID != 0 || second.MessageID != 0 {
|
||||
t.Fatalf("reused request kept stale message: chat=%d message=%d", second.MessageChatID, second.MessageID)
|
||||
}
|
||||
if second.ResolvedAt != "" {
|
||||
t.Fatalf("reused request resolved_at = %q, want empty", second.ResolvedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspacePath(t *testing.T) {
|
||||
if _, err := ValidateWorkspacePath("relative/path"); err == nil {
|
||||
t.Fatal("relative path should be rejected")
|
||||
|
||||
@@ -50,6 +50,11 @@ func (c *Client) redact(text string) string {
|
||||
return strings.ReplaceAll(text, c.token, "<telegram-token>")
|
||||
}
|
||||
|
||||
func (c *Client) SetMyCommands(ctx context.Context, commands []BotCommand) error {
|
||||
var ok bool
|
||||
return c.postJSON(ctx, "setMyCommands", map[string]any{"commands": commands}, &ok)
|
||||
}
|
||||
|
||||
func (c *Client) GetUpdates(ctx context.Context, offset int, timeoutSeconds int) ([]Update, error) {
|
||||
params := map[string]any{
|
||||
"offset": offset,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,26 @@ func SummaryDetailsRawHTMLLimited(summary, detailsHTML string, limit int) string
|
||||
return SummaryDetailsRawHTML(summary, EscapeHTML(suffix))
|
||||
}
|
||||
|
||||
func SummaryRawHTMLSections(summary string, sections []string) string {
|
||||
summary = strings.TrimSpace(summary)
|
||||
sections = nonEmptyHTML(sections)
|
||||
var parts []string
|
||||
if summary != "" {
|
||||
parts = append(parts, EscapeHTML(summary))
|
||||
}
|
||||
for _, section := range sections {
|
||||
parts = append(parts, ExpandableQuoteRawHTML(section))
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func SummaryRawHTMLSectionsLimited(summary string, sections []string, limit int) string {
|
||||
if limit <= 0 {
|
||||
limit = TelegramHTMLMessageLimit
|
||||
}
|
||||
return FitHTMLMessage(SummaryRawHTMLSections(summary, sections), limit)
|
||||
}
|
||||
|
||||
func CodeBlockHTML(language, text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
@@ -197,7 +217,16 @@ func FitHTMLMessage(htmlText string, limit int) string {
|
||||
return truncateHTMLText(htmlText, limit)
|
||||
}
|
||||
|
||||
contentRunes := []rune(strings.TrimSpace(html.UnescapeString(content)))
|
||||
prefix, body, codeLanguage, codeBlock := splitSafeQuotePrefix(content)
|
||||
contentRunes := []rune(strings.TrimSpace(html.UnescapeString(body)))
|
||||
if len(contentRunes) == 0 {
|
||||
replacement := prefix + EscapeHTML(truncatedQuote)
|
||||
if replacement == content {
|
||||
return summaryOnlyHTML(htmlText, limit)
|
||||
}
|
||||
htmlText = htmlText[:contentStart] + replacement + htmlText[contentEnd:]
|
||||
continue
|
||||
}
|
||||
over := len([]rune(htmlText)) - limit
|
||||
keep := len(contentRunes) - over - 80
|
||||
if keep < 0 {
|
||||
@@ -210,7 +239,10 @@ func FitHTMLMessage(htmlText string, limit int) string {
|
||||
if keep > 0 {
|
||||
replacementText = strings.TrimSpace(string(contentRunes[:keep])) + "\n" + truncatedQuote
|
||||
}
|
||||
replacement := EscapeHTML(replacementText)
|
||||
replacement := prefix + EscapeHTML(replacementText)
|
||||
if codeBlock {
|
||||
replacement = prefix + CodeBlockHTML(codeLanguage, replacementText)
|
||||
}
|
||||
if replacement == content {
|
||||
return summaryOnlyHTML(htmlText, limit)
|
||||
}
|
||||
@@ -219,6 +251,81 @@ func FitHTMLMessage(htmlText string, limit int) string {
|
||||
return htmlText
|
||||
}
|
||||
|
||||
func splitSafeQuotePrefix(content string) (prefix, body, codeLanguage string, codeBlock bool) {
|
||||
content = strings.TrimSpace(content)
|
||||
prefix, body = splitLeadingBoldLabel(content)
|
||||
body = strings.TrimSpace(body)
|
||||
if language, code, ok := splitSingleCodeBlock(body); ok {
|
||||
return prefix, code, language, true
|
||||
}
|
||||
return prefix, body, "", false
|
||||
}
|
||||
|
||||
func splitLeadingBoldLabel(content string) (string, string) {
|
||||
if !strings.HasPrefix(content, "<b>") {
|
||||
return "", content
|
||||
}
|
||||
end := strings.Index(content, "</b>")
|
||||
if end < 0 {
|
||||
return "", content
|
||||
}
|
||||
labelEnd := end + len("</b>")
|
||||
label := content[:labelEnd]
|
||||
if !strings.HasSuffix(label, ":</b>") {
|
||||
return "", content
|
||||
}
|
||||
afterLabel := content[labelEnd:]
|
||||
if strings.HasPrefix(afterLabel, " <pre>") {
|
||||
return label + " ", strings.TrimLeft(afterLabel, " ")
|
||||
}
|
||||
if strings.HasPrefix(afterLabel, "\n<pre>") {
|
||||
return label + " ", strings.TrimLeft(afterLabel, "\n")
|
||||
}
|
||||
if strings.HasPrefix(afterLabel, " ") {
|
||||
lineEnd := strings.Index(afterLabel, "\n")
|
||||
if lineEnd < 0 {
|
||||
return content + "\n", ""
|
||||
}
|
||||
prefixEnd := labelEnd + lineEnd + 1
|
||||
return content[:prefixEnd], strings.TrimLeft(content[prefixEnd:], "\n")
|
||||
}
|
||||
rest := strings.TrimLeft(afterLabel, " \n")
|
||||
return label + " ", rest
|
||||
}
|
||||
|
||||
func splitSingleCodeBlock(content string) (string, string, bool) {
|
||||
const preOpen = "<pre>"
|
||||
const preClose = "</pre>"
|
||||
const codeClose = "</code>"
|
||||
if !strings.HasPrefix(content, preOpen+"<code ") || !strings.HasSuffix(content, codeClose+preClose) {
|
||||
return "", "", false
|
||||
}
|
||||
codeStart := len(preOpen)
|
||||
tagEnd := strings.Index(content[codeStart:], ">")
|
||||
if tagEnd < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
tagEnd += codeStart
|
||||
tag := content[codeStart : tagEnd+1]
|
||||
const classPrefix = `class="language-`
|
||||
classStart := strings.Index(tag, classPrefix)
|
||||
if classStart < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
classStart += len(classPrefix)
|
||||
classEnd := strings.Index(tag[classStart:], `"`)
|
||||
if classEnd < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
language := tag[classStart : classStart+classEnd]
|
||||
bodyStart := tagEnd + 1
|
||||
bodyEnd := len(content) - len(codeClose+preClose)
|
||||
if bodyEnd < bodyStart {
|
||||
return "", "", false
|
||||
}
|
||||
return safeCodeLanguage(language), html.UnescapeString(content[bodyStart:bodyEnd]), true
|
||||
}
|
||||
|
||||
func largestBlockquoteContent(htmlText, open, close string) (int, int, string) {
|
||||
bestStart := -1
|
||||
bestEnd := -1
|
||||
@@ -270,15 +377,19 @@ func truncateHTMLText(htmlText string, limit int) string {
|
||||
limit = TelegramHTMLMessageLimit
|
||||
}
|
||||
suffix := "\n...[truncated]"
|
||||
runes := []rune(htmlText)
|
||||
if len(runes) <= limit {
|
||||
if len([]rune(htmlText)) <= limit {
|
||||
return htmlText
|
||||
}
|
||||
plain := stripSimpleHTML(htmlText)
|
||||
runes := []rune(plain)
|
||||
keep := limit - len([]rune(suffix))
|
||||
if keep < 0 {
|
||||
keep = 0
|
||||
}
|
||||
return string(runes[:keep]) + suffix
|
||||
if keep > len(runes) {
|
||||
keep = len(runes)
|
||||
}
|
||||
return EscapeHTML(strings.TrimSpace(string(runes[:keep]))) + EscapeHTML(suffix)
|
||||
}
|
||||
|
||||
func ChunkText(text string, max int) []string {
|
||||
@@ -321,9 +432,13 @@ func ParseApprovalCallbackData(data string) (int64, string, bool) {
|
||||
return 0, "", false
|
||||
}
|
||||
switch parts[2] {
|
||||
case "accept", "acceptForSession", "decline", "cancel", "details":
|
||||
case "accept", "acceptForSession", "acceptWithExecpolicyAmendment", "decline", "cancel", "details":
|
||||
return id, parts[2], true
|
||||
default:
|
||||
if strings.HasPrefix(parts[2], "networkPolicy") {
|
||||
index, err := strconv.Atoi(strings.TrimPrefix(parts[2], "networkPolicy"))
|
||||
return id, parts[2], err == nil && index >= 0
|
||||
}
|
||||
return 0, "", false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,72 @@ func TestApprovalCallbackData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalCallbackDataForStructuredDecision(t *testing.T) {
|
||||
data := ApprovalCallbackData(12, "acceptWithExecpolicyAmendment")
|
||||
id, decision, ok := ParseApprovalCallbackData(data)
|
||||
if !ok || id != 12 || decision != "acceptWithExecpolicyAmendment" {
|
||||
t.Fatalf("unexpected structured callback parse: id=%d decision=%s ok=%v", id, decision, ok)
|
||||
}
|
||||
data = ApprovalCallbackData(12, "networkPolicy0")
|
||||
id, decision, ok = ParseApprovalCallbackData(data)
|
||||
if !ok || id != 12 || decision != "networkPolicy0" {
|
||||
t.Fatalf("unexpected network callback parse: id=%d decision=%s ok=%v", id, decision, ok)
|
||||
}
|
||||
if _, _, ok := ParseApprovalCallbackData("approval:12:networkPolicyx"); ok {
|
||||
t.Fatal("invalid network policy callback should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalMarkupHonorsAvailableDecisions(t *testing.T) {
|
||||
raw := json.RawMessage(`{"availableDecisions":["accept",{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["git","push"]}},"decline"]}`)
|
||||
markup := approvalMarkupForPayload(42, raw)
|
||||
var labels []string
|
||||
for _, row := range markup.InlineKeyboard {
|
||||
for _, button := range row {
|
||||
labels = append(labels, button.Text)
|
||||
}
|
||||
}
|
||||
joined := strings.Join(labels, "|")
|
||||
for _, want := range []string{"Approve", "Approve rule", "Deny", "Details"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("approval markup missing %q in %#v", want, markup.InlineKeyboard)
|
||||
}
|
||||
}
|
||||
if strings.Contains(joined, "Cancel") {
|
||||
t.Fatalf("cancel should not be shown when Codex does not advertise it: %#v", markup.InlineKeyboard)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalResponseForCommandStructuredDecision(t *testing.T) {
|
||||
approval := store.PendingApproval{
|
||||
Kind: "item/commandExecution/requestApproval",
|
||||
PayloadJSON: `{"availableDecisions":[{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["git","push"]}},{"applyNetworkPolicyAmendment":{"network_policy_amendment":{"action":"allow","host":"example.com"}}}]}`,
|
||||
}
|
||||
response, ok := approvalResponse(approval, "acceptWithExecpolicyAmendment").(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("structured command response should be a map")
|
||||
}
|
||||
decision, ok := response["decision"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("decision should be structured: %#v", response["decision"])
|
||||
}
|
||||
if _, ok := decision["acceptWithExecpolicyAmendment"]; !ok {
|
||||
t.Fatalf("missing execpolicy decision: %#v", decision)
|
||||
}
|
||||
|
||||
response, ok = approvalResponse(approval, "networkPolicy0").(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("network command response should be a map")
|
||||
}
|
||||
decision, ok = response["decision"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("network decision should be structured: %#v", response["decision"])
|
||||
}
|
||||
if _, ok := decision["applyNetworkPolicyAmendment"]; !ok {
|
||||
t.Fatalf("missing network policy decision: %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalResponseForPermissions(t *testing.T) {
|
||||
approval := store.PendingApproval{
|
||||
Kind: "item/permissions/requestApproval",
|
||||
@@ -131,9 +197,25 @@ func TestEditReplyMarkupClearsInlineKeyboard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotCommandsUseSingleThreadCommand(t *testing.T) {
|
||||
commands := botCommands()
|
||||
seen := map[string]bool{}
|
||||
for _, command := range commands {
|
||||
seen[command.Command] = true
|
||||
}
|
||||
if !seen["thread"] {
|
||||
t.Fatal("bot command list should include /thread")
|
||||
}
|
||||
for _, removed := range []string{"threads", "resume"} {
|
||||
if seen[removed] {
|
||||
t.Fatalf("bot command list should not include /%s", removed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommand(t *testing.T) {
|
||||
name, args, ok := parseCommand("/resume@my_bot 123")
|
||||
if !ok || name != "resume" || len(args) != 1 || args[0] != "123" {
|
||||
name, args, ok := parseCommand("/thread@my_bot 123")
|
||||
if !ok || name != "thread" || len(args) != 1 || args[0] != "123" {
|
||||
t.Fatalf("unexpected command parse: %q %#v %v", name, args, ok)
|
||||
}
|
||||
}
|
||||
@@ -199,15 +281,49 @@ func TestRenderCodexCommandExecutionItem(t *testing.T) {
|
||||
item := codexThreadItemView{
|
||||
Type: "commandExecution",
|
||||
Command: "go test ./...",
|
||||
CWD: "/workspace/project",
|
||||
AggregatedOutput: &output,
|
||||
ExitCode: &exitCode,
|
||||
}
|
||||
text := renderCodexItemCompleted(item)
|
||||
for _, want := range []string{"Tool call: command finished", "<b>Command</b>", "<pre><code class=\"language-bash\">go test ./...</code></pre>", "Exit code: 0", "<pre><code class=\"language-text\">line 1\nline 2</code></pre>"} {
|
||||
for _, want := range []string{"Tool call: command finished", "<b>CWD:</b> /workspace/project", "<b>Command:</b> <pre><code class=\"language-bash\">go test ./...</code></pre>", "<b>Output:</b> <pre><code class=\"language-text\">line 1\nline 2</code></pre>", "<b>Exit code:</b> 0"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("rendered command item missing %q in %q", want, text)
|
||||
}
|
||||
}
|
||||
summary, _, _ := strings.Cut(text, "<blockquote expandable>")
|
||||
if strings.Contains(summary, "Exit code") {
|
||||
t.Fatalf("exit code should not be in summary: %q", summary)
|
||||
}
|
||||
cwdAt := strings.Index(text, "<b>CWD:</b>")
|
||||
commandAt := strings.Index(text, "<b>Command:</b>")
|
||||
outputAt := strings.Index(text, "<b>Output:</b>")
|
||||
exitAt := strings.Index(text, "<b>Exit code:</b>")
|
||||
if !(cwdAt >= 0 && commandAt > cwdAt && outputAt > commandAt && exitAt > outputAt) {
|
||||
t.Fatalf("command details order should be CWD, Command, Output, fields: %q", text)
|
||||
}
|
||||
if got := strings.Count(text, "<blockquote expandable>"); got != 4 {
|
||||
t.Fatalf("command details should use four quoted sections, got %d in %q", got, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitHTMLMessageKeepsCodeBlockTagsBalanced(t *testing.T) {
|
||||
longOutput := strings.Repeat("0123456789abcdef\n", 600)
|
||||
text := SummaryRawHTMLSectionsLimited("Tool call: command finished", []string{"<b>Output:</b> " + CodeBlockHTML("text", longOutput)}, 900)
|
||||
if len([]rune(text)) > 900 {
|
||||
t.Fatalf("fitted message exceeds limit: %d", len([]rune(text)))
|
||||
}
|
||||
for _, tag := range []string{"<blockquote expandable>", "</blockquote>", "<pre>", "</pre>", "<code class=\"language-text\">", "</code>"} {
|
||||
if !strings.Contains(text, tag) {
|
||||
t.Fatalf("fitted message missing %q in %q", tag, text)
|
||||
}
|
||||
}
|
||||
if strings.Count(text, "<pre>") != strings.Count(text, "</pre>") || strings.Count(text, "<code") != strings.Count(text, "</code>") || strings.Count(text, "<blockquote expandable>") != strings.Count(text, "</blockquote>") {
|
||||
t.Fatalf("fitted message has unbalanced HTML tags: %q", text)
|
||||
}
|
||||
if strings.Contains(text, "<b>Output:</b>\n") {
|
||||
t.Fatalf("label should not be separated from code block by an immediate newline: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCodexStartedItems(t *testing.T) {
|
||||
@@ -226,7 +342,7 @@ func TestRenderDynamicToolDetailsSelectsUsefulArguments(t *testing.T) {
|
||||
Arguments: json.RawMessage(`{"cmd":"go test ./...","irrelevant":{"large":"object"}}`),
|
||||
}
|
||||
text := renderCodexItemCompleted(item)
|
||||
for _, want := range []string{"Tool: functions.exec_command", "<b>cmd</b>", "language-bash", "go test ./..."} {
|
||||
for _, want := range []string{"Tool: functions.exec_command", "<b>cmd:</b>", "language-bash", "go test ./..."} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("rendered tool details missing %q in %q", want, text)
|
||||
}
|
||||
@@ -244,20 +360,55 @@ func TestRenderApprovalDetailsAvoidsRawJSONDump(t *testing.T) {
|
||||
t.Fatalf("approval render missing %q in %q", want, text)
|
||||
}
|
||||
}
|
||||
summary, _, _ := strings.Cut(text, "<blockquote expandable>")
|
||||
for _, unwanted := range []string{"go test ./...", "/workspace/project"} {
|
||||
if strings.Contains(summary, unwanted) {
|
||||
t.Fatalf("approval summary should not include %q in %q", unwanted, summary)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "unused") {
|
||||
t.Fatalf("approval render should omit unused JSON: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolMessageAddsEditedAtBeforeDetails(t *testing.T) {
|
||||
func TestApprovalOnlyToolMessageCanReceiveCompletionDetails(t *testing.T) {
|
||||
exitCode := 0
|
||||
duration := int64(1234)
|
||||
output := "done"
|
||||
tool := toolMessageState{
|
||||
toolHTML: SummaryDetailsHTML("Tool call: command finished\nCommand: go test ./...", "full output"),
|
||||
approvalHTML: SummaryDetailsHTML("Codex requests command approval", "approval details"),
|
||||
}
|
||||
tool.toolHTML = renderCodexItemCompleted(codexThreadItemView{
|
||||
Type: "commandExecution",
|
||||
Command: "go test ./...",
|
||||
CWD: "/workspace/project",
|
||||
ExitCode: &exitCode,
|
||||
DurationMs: &duration,
|
||||
AggregatedOutput: &output,
|
||||
})
|
||||
text := tool.html()
|
||||
for _, want := range []string{"Tool call: command finished", "<b>Exit code:</b> 0", "<b>Duration ms:</b>", "1234", "Codex requests command approval"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("combined approval tool message missing %q in %q", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolMessageAddsEditedAtInsideDetails(t *testing.T) {
|
||||
tool := toolMessageState{
|
||||
toolHTML: SummaryDetailsHTML("Tool call: command finished", "full output"),
|
||||
editedAt: "2026-05-21 12:34:56 UTC",
|
||||
}
|
||||
text := tool.html()
|
||||
want := "Command: go test ./...\nEdited at: 2026-05-21 12:34:56 UTC\n<blockquote expandable>"
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("edited timestamp not placed before details: %q", text)
|
||||
summary, details, ok := strings.Cut(text, "<blockquote expandable>")
|
||||
if !ok {
|
||||
t.Fatalf("tool message should contain details quote: %q", text)
|
||||
}
|
||||
if strings.Contains(summary, "Edited at") {
|
||||
t.Fatalf("edited timestamp should not be in summary: %q", summary)
|
||||
}
|
||||
if !strings.Contains(details, "<b>Edited at:</b> 2026-05-21 12:34:56 UTC") {
|
||||
t.Fatalf("edited timestamp not placed inside details: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +424,7 @@ func TestToolMessageFitsCombinedApprovalDetails(t *testing.T) {
|
||||
if len([]rune(text)) > TelegramHTMLMessageLimit {
|
||||
t.Fatalf("tool message exceeds Telegram limit: %d", len([]rune(text)))
|
||||
}
|
||||
for _, want := range []string{"Tool call: command finished", "Codex requests command approval", "Edited at: 2026-05-21 12:34:56 UTC", "...[truncated]"} {
|
||||
for _, want := range []string{"Tool call: command finished", "Codex requests command approval", "<b>Edited at:</b> 2026-05-21 12:34:56 UTC", "...[truncated]"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("fitted tool message missing %q in %q", want, text)
|
||||
}
|
||||
|
||||
@@ -74,3 +74,8 @@ type EditMessageTextOptions struct {
|
||||
ParseMode string `json:"parse_mode,omitempty"`
|
||||
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
|
||||
}
|
||||
|
||||
type BotCommand struct {
|
||||
Command string `json:"command"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user