Skip to content

feat(line): inline-button approval prompts (Allow Once / Session / Always / Deny) - #23581

Closed
wuwushi4 wants to merge 5 commits into
NousResearch:mainfrom
wuwushi4:feat/line-approval-buttons
Closed

feat(line): inline-button approval prompts (Allow Once / Session / Always / Deny)#23581
wuwushi4 wants to merge 5 commits into
NousResearch:mainfrom
wuwushi4:feat/line-approval-buttons

Conversation

@wuwushi4

Copy link
Copy Markdown

Summary

LINE's dangerous-command approval today is text-only — the user has to type /approve, /approve session, /approve always, or /deny into the chat. Every other platform with a button UI (Telegram, Slack, Discord, Feishu, QQBot) renders four tappable buttons instead. This PR brings LINE up to parity using Template Buttons + postback dispatch.

Surfaced by a user in Taiwan running a private LINE OA who pointed out the friction: "在 LINE 上面如果遇到需要使用者允許的指令的話,會有點麻煩 … 之前 Telegram 聊天室窗中這會變成一個可以點按的按鈕,方便許多" (rough English: "approval prompts in LINE are clunky — Telegram makes them tappable buttons, much easier").

How it works

LineAdapter.send_exec_approval sends two LINE messages in one Push call:

  1. Command preview — a plain text bubble carrying the command + reason. Separated because the Buttons template's text field caps at 160 chars, which is too tight for arbitrary shell commands.
  2. Buttons template — four postback actions: ✅ Allow Once / ✅ Session / ✅ Always / ❌ Deny. Each carries postback data {\"action\": \"approve\", \"choice\": <...>, \"approval_id\": <int>}.

_handle_postback_event is refactored to dispatch on the action field — the existing slow-LLM show_response path is untouched (regression test included); a new approve branch routes the choice into tools.approval.resolve_gateway_approval(session_key, choice) — the same unblock primitive every other platform's button flow uses.

The _approval_state map (approval_id → session_key) mirrors Telegram's exactly, with pop-based discipline: a second tap on the same prompt is a safe no-op and gets an "already resolved" reply via the fresh postback reply token.

Trade-offs documented in the docstring

  • Push, not Reply. Approval prompts fire mid-agent-turn, well after the inbound message's reply token has expired (~60s window). Each prompt costs one Push call against the LINE quota (200 free/month on the developer tier). In exchange the user gets a tappable UX, and the per-tap acknowledgement messages reuse the fresh postback reply token, so confirmations stay free.
  • No message edit. LINE has no edit-message API, so we can't strike out the prompt and replace it with "✅ Approved by …" the way Telegram does. Instead we send a follow-up text bubble with the chosen label (✅ Approved once, ✅ Approved permanently, ❌ Denied, etc.).

Duck-typing contract

gateway/run.py:_approval_notify_sync does:

if getattr(type(_status_adapter), \"send_exec_approval\", None) is not None:
    # ... call it; on SendResult(success=False), log warning and fall through to text

So adding the method on the class is enough — no base-class change, no platform-registry change, no env or config knob. Adapters that fail to push will still degrade cleanly to the existing text path.

Tests

tests/gateway/test_line_approval_buttons.py — 23 tests, all passing.

  • Builder: four actions, correct choice ordering (once → session → always → deny), action-label / altText / template-text caps respected, postback data well-formed JSON.
  • send_exec_approval: pushes preview + buttons in one call, command and reason show in preview bubble, approval_id increments, session is recorded only after a successful send (failed Push leaves no dangling state), long commands are truncated, returns SendResult(success=False) when disconnected so the gateway can fall back to text.
  • _handle_approval_postback: each of the four choices routes to resolve_gateway_approval with the right kwargs; double-tap is idempotent and surfaces a user-visible "already resolved" notice; unknown choice or non-int approval_id are silently ignored; confirmation reply uses the postback reply token; existing show_response postback path still works after the dispatch-on-action refactor.
  • Class-level visibility: getattr(type(adapter), \"send_exec_approval\", None) is not None — guards the duck-typing probe in gateway/run.py.

The existing tests/gateway/test_line_plugin.py (73 tests) still passes unchanged.

Test plan

  • Unit suite (23 new + 73 existing LINE tests pass)
  • Live verified on a real LINE Official Account: prompt appeared, tapping Always produced LINE button resolved 1 approval(s) for session agent:main:line:dm:Uxxxx (choice=always) and the agent then ran the gated command end-to-end.
  • Live verified in group / room (untested — same code path, same fix applies)

Note on overlap with #23569

This branch is based on a clean origin/main and does not include the create_sourcebuild_source typo fix from #23569. Without that fix, inbound LINE messages don't dispatch at all, so this feature can't be triggered end-to-end on plain main. Suggested merge order: land #23569 first (1-line fix), then this PR rebases cleanly on top.

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/feature New feature or request P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins labels May 11, 2026
AllenLai1216 and others added 5 commits May 20, 2026 10:43
…ways / Deny)

Adds `LineAdapter.send_exec_approval` so dangerous-command approval
prompts render as a Template Buttons bubble with four postback actions
— matching the Telegram / Slack / Discord / Feishu / QQBot inline-button
UX instead of forcing the user to type `/approve`, `/approve session`,
`/approve always`, or `/deny` into the chat.

## How it works

Sends two LINE messages in a single Push call:

1. A plain text bubble carrying the command preview + reason.  Lives
   separately because the Buttons template's `text` field caps at 160
   chars — fine for the prompt, far too short for arbitrary shell
   commands.
2. A Buttons template with four postback actions:
   ``✅ Allow Once`` / ``✅ Session`` / ``✅ Always`` / ``❌ Deny``.
   Each action carries postback data
   ``{"action": "approve", "choice": <...>, "approval_id": <int>}``.

`_handle_postback_event` dispatches on `action`: the existing slow-LLM
`show_response` path is untouched; the new `approve` branch routes the
choice into ``tools.approval.resolve_gateway_approval(session_key,
choice)`` — the same unblock primitive every other platform's button
flow uses.

The `_approval_state` map (approval_id → session_key) mirrors
Telegram's exactly, with a `pop`-based discipline so a second tap on
the same prompt is a safe no-op (and gets an "already resolved" reply
back via the fresh postback token).

## Why Push and not Reply

Approval prompts fire mid-agent-turn, long after the inbound message's
reply token has expired (~60s window) — so we always Push.  Each
prompt costs one Push call against the LINE quota (200 free/month on
the developer tier).  In exchange the user gets the tappable UX, and
the per-tap *acknowledgement* messages reuse the fresh postback reply
token, so confirmations stay free.

## Why no message edit

LINE has no message-edit API, so unlike Telegram we can't strike out
the prompt and replace it with "✅ Approved by …".  Instead we send a
follow-up bubble with the chosen label (`✅ Approved once`,
`✅ Approved permanently`, `❌ Denied`, etc.) using the postback reply
token.

## Tests

`tests/gateway/test_line_approval_buttons.py` — 23 tests, all passing.
Coverage:

* `build_exec_approval_button_message`: four actions, correct choice
  ordering, label-length / altText / template-text caps respected,
  postback data well-formed.
* `send_exec_approval`: pushes preview + buttons in one call, command
  and reason show in preview bubble, approval_id increments, session
  is only recorded after a successful send (failed Push leaves no
  dangling state), long commands are truncated, returns
  ``SendResult(success=False)`` when disconnected so the gateway can
  fall back to the text prompt.
* `_handle_approval_postback`: each of the four choices (`once`,
  `session`, `always`, `deny`) routes to `resolve_gateway_approval`
  with the right kwargs; double-tap is idempotent and surfaces a
  user-visible "already resolved" notice; unknown choice or non-int
  approval_id are silently ignored; confirmation reply uses the
  postback reply token; the existing `show_response` postback path
  still works after the dispatch-on-action refactor.
* Class-level visibility check: `getattr(type(adapter),
  "send_exec_approval", None)` resolves — necessary because
  `gateway/run.py:_approval_notify_sync` uses exactly that
  duck-typing probe to decide whether to use buttons vs. text.

Also verified live against a real LINE Official Account on the
trycloudflare-down → ngrok tunnel path: prompt appeared, tapping
``Always`` produced
``LINE button resolved 1 approval(s) for session
agent:main:line:dm:Uxxxx (choice=always)`` and the agent
proceeded to run the gated command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two AttributeError crashes prevented inbound LINE messages from
reaching the agent:

- `self.create_source` does not exist on `BasePlatformAdapter`; the
  factory is `self.build_source` (used by IRC, Teams, etc.).
- `MessageType.IMAGE` is not a member of the enum — `PHOTO` is.  The
  ternary fallback also mis-classified audio, video, file, sticker,
  and location messages as images.

Mirrors PR NousResearch#23867.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ch hard rules

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…o-patch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wuwushi4
wuwushi4 force-pushed the feat/line-approval-buttons branch from 62ee339 to a139ade Compare May 20, 2026 03:03
@wuwushi4

Copy link
Copy Markdown
Author

Superseded by #29053.

Rebased onto v2026.5.16 with a clean scope — the new PR contains only the LINE inline-button approval feature (plugins/platforms/line/adapter.py + new test file). The accidental extra commits (browser supervisor, skill manager, run agent prompt) that landed here after my local rebase are dropped — they are personal config tweaks, not relevant to upstream.

Same author, same approach, same tests. Please review #29053 instead. Thanks!

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

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P2 Medium — degraded but workaround exists type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants