Skip to content

fix: send-by-default connect + ChatGPT prepare/send flow - #171

Merged
mashkovd merged 2 commits into
mainfrom
fix/send-enabled-default-and-chatgpt-flow
May 23, 2026
Merged

mashkovd merged 2 commits into
mainfrom
fix/send-enabled-default-and-chatgpt-flow

Conversation

@mashkovd

Copy link
Copy Markdown
Member

Summary

Fixes two production send failures (observed from ChatGPT and Claude mobile connectors).

1. Connected accounts couldn't send (send_enabled=false)

send_enabled defaulted to read-only and the connect UI presented sending as a buried opt-in, so users who connected ended up unable to send (e.g. a read-only connection silently produced send_message:draft).

  • Connect UI now defaults to send-enabled: the wizard permissions screen pre-selects "Read + send", and the non-wizard phone screen pre-checks the send_optin checkbox.
  • Read-only stays an explicit opt-out; the conservative server-side fallback (field absent → read-only) is kept, so programmatic posts remain safe.
  • New admin tool set_account_send(telegram_id, enabled) (scope admin:users) wraps the existing Store.SetSendEnabled, so operators can toggle a per-account gate without a manual SQL UPDATE.

2. ChatGPT stalled in the two-step send flow

ChatGPT called prepare_send_message, then waited for a user turn instead of chaining send_message; by the time it retried, the 5m token had expired.

  • prepare_send_message response now carries a next_step field instructing the model to call send_message immediately with the same args + confirmation_id.
  • Confirmation TTL 5m → 10m to absorb a user turn between the two calls.
  • Tool descriptions reworked: single-step send is the documented default, two-step is optional.

Test plan

  • go build ./..., go vet ./..., gofmt clean on touched files
  • go test ./... green
  • New TestEnableUI_DefaultsToSendEnabled locks in the send-default UI
  • Existing TestEnableAccess_* (read-only + opt-in paths) still pass
  • Manual: connect a fresh account via browser → defaults to send-enabled; sending works from Claude/ChatGPT

Note: production account user_id=384 was already unblocked out-of-band via SQL while this lands.

Two send failures surfaced in production:

1. Connected accounts could not send because send_enabled defaulted to
   read-only and the connect UI buried the opt-in. Flip the connect UI to
   default send-enabled (wizard radio + non-wizard checkbox pre-selected),
   keeping read-only as an explicit opt-out and the conservative server-side
   fallback (absent field -> read-only). Add set_account_send admin tool so
   operators can toggle send_enabled without a manual SQL UPDATE.

2. ChatGPT stalled after prepare_send_message, never chaining send_message,
   and the 5m token expired before a manual retry. Add a next_step field to
   the prepare response instructing the model to call send_message
   immediately, extend the confirmation TTL to 10m, and reword the tool
   descriptions so single-step send is the documented default.
@mashkovd

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented May 23, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 4m 3s —— View job


Review complete

  • Gather context and read changed files
  • Review internal/mcp/confirm.go
  • Review internal/mcp/server.go
  • Review internal/mcp/tools.go
  • Review internal/oauth/enable_access_page.go
  • Review internal/oauth/enable_access_page_test.go
  • Review internal/oauth/server.go
  • Post inline comments and submit verdict

Result: changes requested — 2 P2, 1 P3 (no P1)

The core logic is sound: the send-default UI flip is clean, the SendOptIn: true default in the non-wizard path is correct, and the next_step field is a reasonable prompt-steering approach for ChatGPT. The new TestEnableUI_DefaultsToSendEnabled locks in the UI default correctly.

P2 — must address before merge

1. prepare_pin_message / pin_message descriptions still say "5m" (tools.go:494, tools.go:560)
ConfirmationTTL is a single constant shared by send and pin confirmations. The PR updated the send tool descriptions but not the pin ones — they still read "valid for 5m". LLM clients relying on the pin tool description will get incorrect expiry information. Fix this →

2. No unit test for toolSetAccountSend (inline comment on tools.go:879)
New admin MCP tool has zero test coverage. The scope check, happy path, and ErrUserNotFound branch are all untested.

P3 — nice to have

3. next_step hard-codes "10 minutes" as prose (inline comment on tools.go:416)
Same class of drift as the 5m strings this PR is fixing. Better to use c.ExpiresAt.UTC().Format(time.RFC3339) so it auto-tracks the constant.

4. Stale comment in confirm.go:19: struct docstring still says short-lived (≤5m). (Not in the diff — update manually.)

@claude

claude Bot commented May 23, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 34s —— View job


Review Complete

  • Gather context and read changed files
  • Review internal/mcp/confirm.go
  • Review internal/mcp/tools.go
  • Review internal/mcp/server.go
  • Review internal/oauth/enable_access_page.go
  • Review internal/oauth/enable_access_page_test.go
  • Review internal/oauth/server.go
  • Post inline findings
  • Submit verdict

Verdict: Changes requested — 0 P1, 1 P2, 2 P3.

The UI default-to-send logic, next_step field, TTL bump, and new TestEnableUI_DefaultsToSendEnabled test are all clean and correct.

P2 — internal/mcp/tools.go line 871: set_account_send returns {"ok": true} even when SetSendEnabled is a no-op (user has no active session row). SetSendEnabled is documented as "nil error when no active session" — so an operator enabling sends for a user whose session was revoked or never provisioned gets a silent false positive. SetAccessTier avoids this with a RowsAffected == 0 guard; the same check is needed here.

P3 — internal/mcp/confirm.go line 19: Confirmation struct comment says "≤5m" but TTL is now 10 minutes.

P3 — internal/mcp/tools.go line 846: telegram_id and enabled lack mcplib.Required() schema annotations. Consistent with existing admin tools, but enabled silently defaults to false when omitted, which disables sends — a footgun for operators.

Comment thread internal/mcp/tools.go Outdated
}
return toolErr("set_account_send: %v", err), nil
}
err = s.Store.SetSendEnabled(ctx, targetUID, enabled)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Silent false-positive when no session row exists.

SetSendEnabled is documented as "A no-op (nil error) when the user has no active session." So if an operator calls set_account_send for a user whose session was revoked (or who has never provisioned a Telegram session at all), the UPDATE matches zero rows, returns nil, and the tool responds {"ok": true, "send_enabled": true} — a false positive. The operator has no way to distinguish "flag updated" from "row not found."

SetAccessTier avoids this by checking RowsAffected == 0 and returning an error. The same guard is needed here, or SetSendEnabled should return a rows-affected count.

Suggested fix — check after the call:

err = s.Store.SetSendEnabled(ctx, targetUID, enabled)
s.audit(ctx, id, "set_account_send", "", err, startedAt)
if err != nil {
    return toolErr("set_account_send: %v", err), nil
}
// SetSendEnabled silently no-ops when no active session row exists.
// Surface this so the caller can tell the difference from a real update.
active, sessionErr := s.Store.HasActiveSession(ctx, targetUID)
if sessionErr == nil && !active {
    return toolErr("no active Telegram session for telegram_id %d — connect the account first", tgID), nil
}

Alternatively, extend SetSendEnabled/Store to return the number of rows affected. Fix this →

Comment thread internal/mcp/confirm.go
const ConfirmationTTL = 5 * time.Minute
// send/pin. 10 minutes accommodates multi-turn LLM reasoning delays and
// connectors (e.g. ChatGPT) that pause for a user turn between the two calls.
const ConfirmationTTL = 10 * time.Minute

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — The Confirmation struct comment two lines below (line 19 in the file) still reads "≤5m" after the TTL bump to 10 minutes. Should be "≤10m".

Comment thread internal/mcp/tools.go
enabled — bool, required. true enables real sends; false forces dry-run previews.

The user must have an active session. New accounts are send-enabled by default; use enabled=false to revoke sending for a specific account without revoking its scope or session.`),
mcplib.WithNumber("telegram_id",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — Neither telegram_id nor enabled carries a mcplib.Required() annotation, so the JSON Schema exposed to the LLM does not declare them required. Consistent with the existing toolSetAccess pattern, but enabled silently defaults to false when omitted — an operator who forgets it will silently disable sends for the target account rather than getting a schema-level rejection.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 1 P2 (set_account_send silently returns ok=true when no active session row exists — false positive for admin operators), 2 P3 (stale ≤5m comment in confirm.go, missing Required() annotations on set_account_send params). The rest of the change — send-enabled UI defaults, next_step field, TTL bump, new test — is clean.

Comment thread internal/mcp/tools.go Outdated
"expires_at": c.ExpiresAt.UTC(),
"payload_hash": hash,
"next_step": fmt.Sprintf(
"Now call send_message with the same peer and text, mode=\"send\", and confirmation_id=%q. Do this immediately without asking the user to confirm — the token expires in 10 minutes.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — duplicated TTL literal in prose: "10 minutes" is hard-coded here, same class of bug as the 5m strings this PR is fixing. If ConfirmationTTL is bumped again, this prose will silently go stale. Use the actual expiry instead:

Suggested change
"Now call send_message with the same peer and text, mode=\"send\", and confirmation_id=%q. Do this immediately without asking the user to confirm — the token expires in 10 minutes.",
"Now call send_message with the same peer and text, mode=\"send\", and confirmation_id=%q. Do this immediately without asking the user to confirm — the token expires at %s.",
c.ID, c.ExpiresAt.UTC().Format(time.RFC3339),

Comment thread internal/mcp/tools.go
return jsonResult(map[string]any{"telegram_id": tgID, "send_enabled": enabled, "ok": true})
}
return tool, handler
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — no unit test for toolSetAccountSend: This is a new admin tool that modifies per-account send state. The following paths are untested:

  • Scope check: an identity without admin:users must be rejected
  • Happy path: SetSendEnabled is called with correct (userID, enabled) and the response contains {telegram_id, send_enabled, ok}
  • ErrUserNotFound branch: unknown telegram_id returns a tool error, not a Go error
  • enabled defaults to false when the argument is omitted — silently disabling send could be a footgun; at minimum the default should be verified by a test

The existing toolSetAccess and toolGetUserAuditLog have similar patterns; add at least happy-path + scope + not-found tests here. Fix this →

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 1 P2 (missing tests for new admin tool toolSetAccountSend — scope check, happy path, ErrUserNotFound untested), 1 P2 (prepare_pin_message and pin_message tool descriptions still say '5m' while ConfirmationTTL is now 10m — shared constant, both needed updating), 1 P3 (TTL hard-coded as prose '10 minutes' in next_step field). Core logic for send-default UX and ChatGPT two-step fix is correct.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 39c00ae9fd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/mcp/tools.go Outdated
}
return toolErr("set_account_send: %v", err), nil
}
err = s.Store.SetSendEnabled(ctx, targetUID, enabled)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject set_account_send when no active session exists

set_account_send always returns success after calling Store.SetSendEnabled, but that store method is a no-op when the user has no active Telegram session. In that case this tool still reports "ok": true/"send_enabled": ... even though nothing was changed, which violates the tool’s own contract (“The user must have an active session”) and can mislead operators into believing send access was updated when it was not.

Useful? React with 👍 / 👎.

Comment thread internal/mcp/tools.go
if tgID <= 0 {
return mcplib.NewToolResultError("telegram_id is required and must be a positive integer"), nil
}
enabled := boolArg(args, "enabled", false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce required enabled flag in set_account_send

The handler treats enabled as optional by parsing it with boolArg(..., false) and never checking key presence, so omitting enabled silently disables sending. Because the tool contract marks enabled as required, a malformed or partially generated admin call can revoke send access unintentionally instead of returning a validation error.

Useful? React with 👍 / 👎.

@mctl-claude-remote mctl-claude-remote Bot added steward:owned pr-steward owned steward:escalated pr-steward escalated to a human labels May 23, 2026
- set_account_send: surface a clear error when the target user has no
  active session row instead of a silent ok=true. SetSendEnabled now
  returns rows-affected so the tool can distinguish a real update from a
  no-op; the enable_access caller ignores the count.
- Add Required() annotations to set_account_send params.
- Add unit tests for set_account_send: admin-scope gate, happy path,
  unknown telegram_id, and no-active-session.
- Update prepare_pin_message / pin_message descriptions from 5m to 10m
  to match the shared ConfirmationTTL.
- next_step now embeds the actual expiry timestamp instead of hard-coded
  prose, and the Confirmation struct comment reflects the 10m TTL.
@mashkovd

Copy link
Copy Markdown
Member Author

@claude review

Addressed all findings from the prior review:

  • P2 set_account_send silent no-op → now returns a clear error when the user has no active session (SetSendEnabled returns rows-affected)
  • P2 pin tool descriptions 5m → 10m
  • P2 added unit tests for set_account_send (scope, happy path, not-found, no-session)
  • P3 Required() annotations on params; confirm.go comment 5m → 10m; next_step embeds actual expiry timestamp

@claude

claude Bot commented May 23, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 1m 55s —— View job


Review complete

  • Gather context and read changed files
  • Review internal/mcp/confirm.go
  • Review internal/mcp/tools.go
  • Review internal/mcp/tools_test.go
  • Review internal/db/store.go
  • Review internal/oauth/ changes
  • Post inline findings and verdict

Verdict: Approved — 0 P1, 0 P2, 0 P3.

Every finding from the prior two review rounds is cleanly resolved:

Finding Resolution
P2 — set_account_send false-positive ok=true when no session SetSendEnabled now returns (int64, error); rows == 0 surfaces a clear error to the caller
P2 — pin tool descriptions still said "5m" prepare_pin_message + pin_message descriptions updated to "10m"
P2 — no unit tests for toolSetAccountSend Four tests cover scope rejection, happy path (with persistence check), user not found, and no-active-session
P3 — telegram_id/enabled missing Required() Both params now carry mcplib.Required()
P3 — confirm.go struct comment said "≤5m" Updated to "≤10m"
P3 — next_step hard-coded "10 minutes" as prose Now uses c.ExpiresAt.UTC().Format(time.RFC3339)

The enable_access.go call site correctly updates to the new (int64, error) signature (discarding rows, which is correct since the session is provisioned before this is called). seedAccountWithSession in the test helper is well-structured and the no-active-session test correctly omits the telegram_accounts INSERT to exercise the rows == 0 guard.

@claude

claude Bot commented May 23, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 1m 33s —— View job


Review complete

  • Gather context and read changed files
  • Verify P2: set_account_send rows-affected guard
  • Verify P2: pin tool descriptions updated to 10m
  • Verify P2: unit tests for toolSetAccountSend
  • Verify P3: Required() annotations on params
  • Verify P3: confirm.go comment updated to 10m
  • Verify P3: next_step embeds actual expiry timestamp
  • Post inline findings (none)
  • Submit verdict: approved

Result: approved — no P1/P2/P3 findings.

All prior findings are cleanly addressed:

  • SetSendEnabled now returns (int64, error); the handler checks rows == 0 and returns a clear "no active Telegram session" error (tools.go:880).
  • prepare_pin_message and pin_message descriptions both say "10m" (tools.go:494, tools.go:560).
  • Four unit tests cover toolSetAccountSend: scope rejection, happy path (verifies DB persistence via IsSendEnabled), user-not-found, and no-active-session (tools_test.go:451–546).
  • telegram_id and enabled both carry mcplib.Required() (tools.go:847, tools.go:850).
  • confirm.go struct comment updated to "≤10m" (confirm.go:19).
  • next_step uses c.ExpiresAt.UTC().Format(time.RFC3339) instead of hardcoded prose (tools.go:417).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (0 P3). All prior review findings addressed — good to merge.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (0 P3). All prior review findings addressed — SetSendEnabled rows-affected guard, pin tool TTL descriptions, set_account_send tests, Required() annotations, confirm.go comment, and next_step expiry format. Good to merge.

@mashkovd
mashkovd merged commit 88ea49b into main May 23, 2026
8 checks passed
@mashkovd
mashkovd deleted the fix/send-enabled-default-and-chatgpt-flow branch May 23, 2026 19:54

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9bc70e0e7c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/mcp/tools.go
return toolErr("set_account_send: %v", err), nil
}
rows, err := s.Store.SetSendEnabled(ctx, targetUID, enabled)
s.audit(ctx, id, "set_account_send", "", err, startedAt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record failed set_account_send attempts as errors

When SetSendEnabled matches zero rows (no active session), this handler returns a tool error, but the audit/metrics are already emitted as success because s.audit(...) is called immediately after SetSendEnabled with err == nil. This produces incorrect ok audit rows and success counters for a failed admin operation, which can hide real access-toggle failures during incident/debug workflows.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

steward:escalated pr-steward escalated to a human steward:owned pr-steward owned

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants