Fix bot kwargs and scoot over unread button to middle

This commit is contained in:
Jack Kingsman
2026-03-12 10:58:38 -07:00
parent 0a20929df6
commit 7db2974481
3 changed files with 109 additions and 61 deletions

View File

@@ -85,25 +85,38 @@ def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
)
positional_capacity = len(positional_params)
if not has_varargs and positional_capacity < 8:
raise ValueError(
"Bot function must accept at least 8 positional parameters before optional extras"
)
base_args = [object()] * 8
base_keyword_args: dict[str, object] = {
"sender_name": object(),
"sender_key": object(),
"message_text": object(),
"is_dm": object(),
"channel_key": object(),
"channel_name": object(),
"sender_timestamp": object(),
"path": object(),
}
candidate_specs: list[tuple[str, list[object], dict[str, object]]] = []
if has_kwargs or explicit_optional_names:
keyword_args = dict(base_keyword_args)
if has_kwargs or "is_outgoing" in params:
keyword_args["is_outgoing"] = False
if has_kwargs or "path_bytes_per_hop" in params:
keyword_args["path_bytes_per_hop"] = 1
candidate_specs.append(("keyword", [], keyword_args))
if not has_kwargs and explicit_optional_names:
kwargs: dict[str, object] = {}
if has_kwargs or "is_outgoing" in params:
kwargs["is_outgoing"] = False
if has_kwargs or "path_bytes_per_hop" in params:
kwargs["path_bytes_per_hop"] = 1
candidate_specs.append(("keyword", base_args, kwargs))
else:
candidate_specs.append(("mixed_keyword", base_args, kwargs))
if has_varargs or positional_capacity >= 10:
candidate_specs.append(("positional_10", base_args + [False, 1], {}))
if has_varargs or positional_capacity >= 9:
candidate_specs.append(("positional_9", base_args + [False], {}))
if has_varargs or positional_capacity >= 8:
candidate_specs.append(("legacy", base_args, {}))
for call_style, args, kwargs in candidate_specs:
@@ -111,7 +124,7 @@ def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
sig.bind(*args, **kwargs)
except TypeError:
continue
if call_style == "keyword":
if call_style in {"keyword", "mixed_keyword"}:
return BotCallPlan(call_style="keyword", keyword_args=tuple(kwargs.keys()))
return BotCallPlan(call_style=call_style)
@@ -141,6 +154,7 @@ def execute_bot_code(
The code should define a function:
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing, path_bytes_per_hop)`
or use named parameters / `**kwargs`.
that returns either None (no response), a string (single response message),
or a list of strings (multiple messages sent in order).
@@ -221,21 +235,27 @@ def execute_bot_code(
)
elif call_plan.call_style == "keyword":
keyword_args: dict[str, Any] = {}
if "sender_name" in call_plan.keyword_args:
keyword_args["sender_name"] = sender_name
if "sender_key" in call_plan.keyword_args:
keyword_args["sender_key"] = sender_key
if "message_text" in call_plan.keyword_args:
keyword_args["message_text"] = message_text
if "is_dm" in call_plan.keyword_args:
keyword_args["is_dm"] = is_dm
if "channel_key" in call_plan.keyword_args:
keyword_args["channel_key"] = channel_key
if "channel_name" in call_plan.keyword_args:
keyword_args["channel_name"] = channel_name
if "sender_timestamp" in call_plan.keyword_args:
keyword_args["sender_timestamp"] = sender_timestamp
if "path" in call_plan.keyword_args:
keyword_args["path"] = path
if "is_outgoing" in call_plan.keyword_args:
keyword_args["is_outgoing"] = is_outgoing
if "path_bytes_per_hop" in call_plan.keyword_args:
keyword_args["path_bytes_per_hop"] = path_bytes_per_hop
result = bot_func(
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
**keyword_args,
)
result = bot_func(**keyword_args)
else:
result = bot_func(
sender_name,

View File

@@ -835,24 +835,24 @@ export function MessageList({
</div>
{/* Scroll to bottom button */}
{(showJumpToUnread || showScrollToBottom) && (
<div className="absolute bottom-4 right-4 flex items-center gap-2">
{showJumpToUnread && (
<div className="pointer-events-none absolute bottom-4 left-1/2 -translate-x-1/2">
<button
type="button"
onClick={() => {
unreadMarkerRef.current?.scrollIntoView?.({ block: 'center' });
setShowJumpToUnread(false);
}}
className="h-9 rounded-full bg-card hover:bg-accent border border-border px-3 text-sm font-medium shadow-lg transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="pointer-events-auto h-9 rounded-full bg-card hover:bg-accent border border-border px-3 text-sm font-medium shadow-lg transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Jump to unread
</button>
</div>
)}
{showScrollToBottom && (
<button
onClick={scrollToBottom}
className="w-9 h-9 rounded-full bg-card hover:bg-accent border border-border flex items-center justify-center shadow-lg transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="absolute bottom-4 right-4 w-9 h-9 rounded-full bg-card hover:bg-accent border border-border flex items-center justify-center shadow-lg transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title="Scroll to bottom"
aria-label="Scroll to bottom"
>
@@ -872,8 +872,6 @@ export function MessageList({
</svg>
</button>
)}
</div>
)}
{/* Path modal */}
{selectedPath && (

View File

@@ -373,6 +373,30 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name,
)
assert result == "bytes=2"
def test_pure_kwargs_bot_receives_core_fields_and_optional_extras(self):
"""Pure **kwargs bots are first-class and receive the full payload by keyword."""
code = """
def bot(**kwargs):
return (
f"{kwargs.get('sender_name')}|{kwargs.get('message_text')}|"
f"{kwargs.get('is_outgoing')}|{kwargs.get('path_bytes_per_hop')}"
)
"""
result = execute_bot_code(
code=code,
sender_name="Alice",
sender_key="abc123",
message_text="Hi",
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=None,
path="aabb",
is_outgoing=True,
path_bytes_per_hop=2,
)
assert result == "Alice|Hi|True|2"
def test_channel_message_with_none_sender_key(self):
"""Channel messages correctly pass None for sender_key."""
code = """
@@ -489,6 +513,12 @@ class TestBotCodeValidation:
}
)
def test_pure_kwargs_code_passes(self):
"""Pure **kwargs bots are valid."""
from app.routers.fanout import _validate_bot_config
_validate_bot_config({"code": "def bot(**kwargs):\n return kwargs.get('message_text')"})
def test_syntax_error_raises(self):
"""Syntax error in code raises HTTPException."""
from fastapi import HTTPException