mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
fix(slack): complete agent view workspace routing
This commit is contained in:
parent
f1328a6bfd
commit
4554fe128a
10 changed files with 599 additions and 84 deletions
|
|
@ -63,9 +63,17 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)
|
|||
``direct_messages_topic_id`` when the Bot API supports it.
|
||||
"""
|
||||
thread_id = getattr(source, "thread_id", None)
|
||||
if thread_id is None:
|
||||
metadata = {"thread_id": thread_id} if thread_id is not None else {}
|
||||
# Slack workspace identity is durable routing state, not ephemeral event
|
||||
# metadata. Carry it on every outbound path (including unthreaded sends)
|
||||
# so a multi-workspace Socket Mode gateway never falls back to its primary
|
||||
# WebClient after an async, stream, or recovery boundary.
|
||||
if _platform_name(getattr(source, "platform", None)) == "slack":
|
||||
scope_id = getattr(source, "scope_id", None)
|
||||
if scope_id:
|
||||
metadata["slack_team_id"] = str(scope_id)
|
||||
if not metadata:
|
||||
return None
|
||||
metadata = {"thread_id": thread_id}
|
||||
if _platform_name(getattr(source, "platform", None)) == "telegram" and getattr(source, "chat_type", None) == "dm":
|
||||
metadata["telegram_dm_topic_reply_fallback"] = True
|
||||
tid = str(thread_id)
|
||||
|
|
@ -3202,6 +3210,30 @@ class BasePlatformAdapter(ABC):
|
|||
"""
|
||||
pass
|
||||
|
||||
async def _stop_typing_with_metadata(self, chat_id: str, metadata=None) -> None:
|
||||
"""Stop typing while preserving platform-specific routing metadata.
|
||||
|
||||
Most adapters key typing state by chat and retain the historical
|
||||
``stop_typing(chat_id)`` signature. Slack AI status is per thread and
|
||||
workspace, however, so losing metadata can clear a sibling thread or
|
||||
leave the current one active. Introspect at this shared chokepoint so
|
||||
existing adapters remain source-compatible.
|
||||
"""
|
||||
if metadata:
|
||||
try:
|
||||
params = inspect.signature(self.stop_typing).parameters
|
||||
accepts_metadata = "metadata" in params or any(
|
||||
param.kind is inspect.Parameter.VAR_KEYWORD
|
||||
for param in params.values()
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
accepts_metadata = False
|
||||
if accepts_metadata:
|
||||
stop_typing = getattr(self, "stop_typing")
|
||||
await stop_typing(chat_id, metadata=metadata)
|
||||
return
|
||||
await self.stop_typing(chat_id)
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
|
@ -3879,7 +3911,7 @@ class BasePlatformAdapter(ABC):
|
|||
# Cancelling _keep_typing alone won't clean that up.
|
||||
if hasattr(self, "stop_typing"):
|
||||
try:
|
||||
await self.stop_typing(chat_id)
|
||||
await self._stop_typing_with_metadata(chat_id, metadata)
|
||||
except Exception:
|
||||
pass
|
||||
self._typing_paused.discard(chat_id)
|
||||
|
|
@ -3889,6 +3921,7 @@ class BasePlatformAdapter(ABC):
|
|||
chat_id: str,
|
||||
typing_task: asyncio.Task | None = None,
|
||||
*,
|
||||
metadata=None,
|
||||
timeout: float = 0.5,
|
||||
stop_attempts: int = 2,
|
||||
) -> None:
|
||||
|
|
@ -3908,7 +3941,7 @@ class BasePlatformAdapter(ABC):
|
|||
attempts = max(1, stop_attempts)
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
await self.stop_typing(chat_id)
|
||||
await self._stop_typing_with_metadata(chat_id, metadata)
|
||||
except Exception:
|
||||
pass
|
||||
if attempt < attempts - 1:
|
||||
|
|
@ -3928,14 +3961,14 @@ class BasePlatformAdapter(ABC):
|
|||
"""Resume typing indicator for a chat after approval resolves."""
|
||||
self._typing_paused.discard(chat_id)
|
||||
|
||||
async def interrupt_session_activity(self, session_key: str, chat_id: str) -> None:
|
||||
async def interrupt_session_activity(self, session_key: str, chat_id: str, metadata=None) -> None:
|
||||
"""Signal the active session loop to stop and clear typing immediately."""
|
||||
if session_key:
|
||||
interrupt_event = self._active_sessions.get(session_key)
|
||||
if interrupt_event is not None:
|
||||
interrupt_event.set()
|
||||
try:
|
||||
await self.stop_typing(chat_id)
|
||||
await self._stop_typing_with_metadata(chat_id, metadata)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -4873,6 +4906,7 @@ class BasePlatformAdapter(ABC):
|
|||
await self._stop_typing_refresh(
|
||||
event.source.chat_id,
|
||||
typing_task,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -5296,6 +5330,7 @@ class BasePlatformAdapter(ABC):
|
|||
await self._stop_typing_refresh(
|
||||
event.source.chat_id,
|
||||
None,
|
||||
metadata=_thread_metadata,
|
||||
stop_attempts=1,
|
||||
)
|
||||
# Final drain/release boundary: force-flush any timer that missed
|
||||
|
|
@ -5464,6 +5499,7 @@ class BasePlatformAdapter(ABC):
|
|||
user_id_alt: Optional[str] = None,
|
||||
chat_id_alt: Optional[str] = None,
|
||||
is_bot: bool = False,
|
||||
scope_id: Optional[str] = None,
|
||||
guild_id: Optional[str] = None,
|
||||
parent_chat_id: Optional[str] = None,
|
||||
message_id: Optional[str] = None,
|
||||
|
|
@ -5487,6 +5523,7 @@ class BasePlatformAdapter(ABC):
|
|||
user_id_alt=user_id_alt,
|
||||
chat_id_alt=chat_id_alt,
|
||||
is_bot=is_bot,
|
||||
scope_id=str(scope_id) if scope_id else None,
|
||||
guild_id=str(guild_id) if guild_id else None,
|
||||
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
|
||||
message_id=str(message_id) if message_id else None,
|
||||
|
|
|
|||
|
|
@ -11756,10 +11756,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
persist_user_timestamp=persist_user_timestamp,
|
||||
)
|
||||
|
||||
# Stop persistent typing indicator now that the agent is done
|
||||
# Stop persistent typing indicator now that the agent is done.
|
||||
# Slack AI status is scoped to a thread/workspace, so preserve the
|
||||
# same routing metadata used by the response delivery path.
|
||||
try:
|
||||
_typing_adapter = self._adapter_for_source(source)
|
||||
if _typing_adapter and hasattr(_typing_adapter, "stop_typing"):
|
||||
_stop_with_metadata = getattr(
|
||||
type(_typing_adapter), "_stop_typing_with_metadata", None
|
||||
)
|
||||
_stop_typing = getattr(type(_typing_adapter), "stop_typing", None)
|
||||
if _typing_adapter and callable(_stop_with_metadata):
|
||||
await _typing_adapter._stop_typing_with_metadata(
|
||||
source.chat_id,
|
||||
self._thread_metadata_for_source(
|
||||
source, self._reply_anchor_for_event(event)
|
||||
),
|
||||
)
|
||||
elif _typing_adapter and callable(_stop_typing):
|
||||
await _typing_adapter.stop_typing(source.chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -12305,10 +12318,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
return response
|
||||
|
||||
except Exception as e:
|
||||
# Stop typing indicator on error too
|
||||
# Stop typing indicator on error too, retaining Slack thread/workspace
|
||||
# routing so a failed turn cannot leave its status visible.
|
||||
try:
|
||||
_err_adapter = self._adapter_for_source(source)
|
||||
if _err_adapter and hasattr(_err_adapter, "stop_typing"):
|
||||
_stop_with_metadata = getattr(
|
||||
type(_err_adapter), "_stop_typing_with_metadata", None
|
||||
)
|
||||
_stop_typing = getattr(type(_err_adapter), "stop_typing", None)
|
||||
if _err_adapter and callable(_stop_with_metadata):
|
||||
await _err_adapter._stop_typing_with_metadata(
|
||||
source.chat_id,
|
||||
self._thread_metadata_for_source(
|
||||
source, self._reply_anchor_for_event(event)
|
||||
),
|
||||
)
|
||||
elif _err_adapter and callable(_stop_typing):
|
||||
await _err_adapter.stop_typing(source.chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -14469,13 +14494,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
reply_to_message_id: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Build the metadata dict platforms need for thread-aware replies."""
|
||||
return self._thread_metadata_for_target(
|
||||
metadata = self._thread_metadata_for_target(
|
||||
getattr(source, "platform", None),
|
||||
getattr(source, "chat_id", None),
|
||||
getattr(source, "thread_id", None),
|
||||
chat_type=getattr(source, "chat_type", None),
|
||||
reply_to_message_id=reply_to_message_id or getattr(source, "message_id", None),
|
||||
)
|
||||
if getattr(source, "platform", None) == Platform.SLACK:
|
||||
team_id = getattr(source, "scope_id", None)
|
||||
if team_id:
|
||||
metadata = dict(metadata or {})
|
||||
metadata["slack_team_id"] = str(team_id)
|
||||
return metadata
|
||||
|
||||
def _thread_metadata_for_target(
|
||||
self,
|
||||
|
|
@ -16373,8 +16404,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
running_agent.interrupt(interrupt_reason)
|
||||
self._invalidate_session_run_generation(session_key, reason=invalidation_reason)
|
||||
adapter = self._adapter_for_source(source)
|
||||
if adapter and hasattr(adapter, "interrupt_session_activity"):
|
||||
await adapter.interrupt_session_activity(session_key, source.chat_id)
|
||||
interrupt_session_activity = getattr(
|
||||
type(adapter), "interrupt_session_activity", None
|
||||
)
|
||||
if adapter and callable(interrupt_session_activity):
|
||||
metadata = self._thread_metadata_for_source(source)
|
||||
try:
|
||||
params = inspect.signature(interrupt_session_activity).parameters
|
||||
accepts_metadata = "metadata" in params or any(
|
||||
param.kind is inspect.Parameter.VAR_KEYWORD
|
||||
for param in params.values()
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
accepts_metadata = False
|
||||
if accepts_metadata:
|
||||
await adapter.interrupt_session_activity(
|
||||
session_key, source.chat_id, metadata=metadata
|
||||
)
|
||||
else:
|
||||
await adapter.interrupt_session_activity(session_key, source.chat_id)
|
||||
if adapter and hasattr(adapter, "get_pending_message"):
|
||||
adapter.get_pending_message(session_key) # consume and discard
|
||||
self._pending_messages.pop(session_key, None)
|
||||
|
|
|
|||
|
|
@ -113,7 +113,10 @@ def _build_full_manifest(
|
|||
"agent_description": "Chat with Hermes in Slack Messages.",
|
||||
}
|
||||
bot_scopes.append("assistant:write")
|
||||
bot_events.append("app_home_opened")
|
||||
# Slack includes current viewing context in Agent DM events only after
|
||||
# this subscription is enabled; the adapter consumes that context to
|
||||
# preserve the referred channel across the agent turn.
|
||||
bot_events.extend(["app_context_changed", "app_home_opened"])
|
||||
|
||||
bot_scopes.sort()
|
||||
bot_events.sort()
|
||||
|
|
|
|||
|
|
@ -466,6 +466,12 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
# session + memory scoping.
|
||||
self._assistant_threads: Dict[Tuple[str, str], Dict[str, str]] = {}
|
||||
self._ASSISTANT_THREADS_MAX = 5000
|
||||
# Agent-view context is per workspace/user (not global): a context
|
||||
# change for one person's Slack split view must never appear in another
|
||||
# person's prompt. Slack also includes this in later DM events, but the
|
||||
# cache bridges lifecycle and message delivery ordering.
|
||||
self._agent_view_contexts: Dict[Tuple[str, str], Dict[str, str]] = {}
|
||||
self._AGENT_VIEW_CONTEXTS_MAX = 5000
|
||||
# Cache for _fetch_thread_context results: cache_key → _ThreadContextCache
|
||||
self._thread_context_cache: Dict[str, _ThreadContextCache] = {}
|
||||
self._THREAD_CACHE_TTL = 60.0
|
||||
|
|
@ -1091,8 +1097,8 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
|
||||
# Register message event handler
|
||||
@self._app.event("message")
|
||||
async def handle_message_event(event, say):
|
||||
await self._handle_slack_message(event)
|
||||
async def handle_message_event(event, say, body):
|
||||
await self._handle_slack_message(event, body)
|
||||
|
||||
# Handle app_mention explicitly. In some Slack app configurations,
|
||||
# channel mentions arrive only as app_mention events rather than the
|
||||
|
|
@ -1102,19 +1108,23 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
# @mention, they share the same event ts — the dedup in
|
||||
# _handle_slack_message (MessageDeduplicator) suppresses the second.
|
||||
@self._app.event("app_mention")
|
||||
async def handle_app_mention(event, say):
|
||||
await self._handle_slack_message(event)
|
||||
async def handle_app_mention(event, say, body):
|
||||
await self._handle_slack_message(event, body)
|
||||
|
||||
@self._app.event("app_home_opened")
|
||||
async def handle_app_home_opened(event, say):
|
||||
await self._handle_app_home_opened(event)
|
||||
async def handle_app_home_opened(event, say, body):
|
||||
await self._handle_app_home_opened(event, body)
|
||||
|
||||
@self._app.event("app_context_changed")
|
||||
async def handle_app_context_changed(event, say, body):
|
||||
await self._handle_app_context_changed(event, body)
|
||||
|
||||
# File lifecycle events can arrive around snippet uploads even when
|
||||
# the actual user message is what we care about. Ack them so Slack
|
||||
# doesn't log noisy 404 "unhandled request" warnings.
|
||||
@self._app.event("file_shared")
|
||||
async def handle_file_shared(event, say):
|
||||
await self._handle_slack_file_shared(event)
|
||||
async def handle_file_shared(event, say, body):
|
||||
await self._handle_slack_file_shared(event, body)
|
||||
|
||||
@self._app.event("file_created")
|
||||
async def handle_file_created(event, say):
|
||||
|
|
@ -1137,12 +1147,12 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
pass
|
||||
|
||||
@self._app.event("assistant_thread_started")
|
||||
async def handle_assistant_thread_started(event, say):
|
||||
await self._handle_assistant_thread_lifecycle_event(event)
|
||||
async def handle_assistant_thread_started(event, say, body):
|
||||
await self._handle_assistant_thread_lifecycle_event(event, body)
|
||||
|
||||
@self._app.event("assistant_thread_context_changed")
|
||||
async def handle_assistant_thread_context_changed(event, say):
|
||||
await self._handle_assistant_thread_lifecycle_event(event)
|
||||
async def handle_assistant_thread_context_changed(event, say, body):
|
||||
await self._handle_assistant_thread_lifecycle_event(event, body)
|
||||
|
||||
# Register slash command handler(s)
|
||||
#
|
||||
|
|
@ -1763,7 +1773,9 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
last_exc = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
result = await self._get_client(
|
||||
chat_id, team_id=self._metadata_team_id(metadata)
|
||||
).files_upload_v2(
|
||||
channel=chat_id,
|
||||
file=file_path,
|
||||
filename=os.path.basename(file_path),
|
||||
|
|
@ -1890,7 +1902,9 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
chunk_idx + 1,
|
||||
len(chunks),
|
||||
)
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
result = await self._get_client(
|
||||
chat_id, team_id=self._metadata_team_id(metadata)
|
||||
).files_upload_v2(
|
||||
channel=chat_id,
|
||||
file_uploads=file_uploads,
|
||||
initial_comment=initial_comment,
|
||||
|
|
@ -2134,12 +2148,14 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
|
||||
# ----- Reactions -----
|
||||
|
||||
async def _add_reaction(self, channel: str, timestamp: str, emoji: str) -> bool:
|
||||
async def _add_reaction(
|
||||
self, channel: str, timestamp: str, emoji: str, team_id: str = ""
|
||||
) -> bool:
|
||||
"""Add an emoji reaction to a message. Returns True on success."""
|
||||
if not self._app:
|
||||
return False
|
||||
try:
|
||||
await self._get_client(channel).reactions_add(
|
||||
await self._get_client(channel, team_id=team_id or None).reactions_add(
|
||||
channel=channel, timestamp=timestamp, name=emoji
|
||||
)
|
||||
return True
|
||||
|
|
@ -2148,12 +2164,14 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
logger.debug("[Slack] reactions.add failed (%s): %s", emoji, e)
|
||||
return False
|
||||
|
||||
async def _remove_reaction(self, channel: str, timestamp: str, emoji: str) -> bool:
|
||||
async def _remove_reaction(
|
||||
self, channel: str, timestamp: str, emoji: str, team_id: str = ""
|
||||
) -> bool:
|
||||
"""Remove an emoji reaction from a message. Returns True on success."""
|
||||
if not self._app:
|
||||
return False
|
||||
try:
|
||||
await self._get_client(channel).reactions_remove(
|
||||
await self._get_client(channel, team_id=team_id or None).reactions_remove(
|
||||
channel=channel, timestamp=timestamp, name=emoji
|
||||
)
|
||||
return True
|
||||
|
|
@ -2174,7 +2192,9 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
return
|
||||
channel_id = getattr(event.source, "chat_id", None)
|
||||
if channel_id:
|
||||
await self._add_reaction(channel_id, ts, "eyes")
|
||||
await self._add_reaction(
|
||||
channel_id, ts, "eyes", str(getattr(event.source, "scope_id", "") or "")
|
||||
)
|
||||
|
||||
async def on_processing_complete(
|
||||
self, event: MessageEvent, outcome: ProcessingOutcome
|
||||
|
|
@ -2189,11 +2209,12 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
channel_id = getattr(event.source, "chat_id", None)
|
||||
if not channel_id:
|
||||
return
|
||||
await self._remove_reaction(channel_id, ts, "eyes")
|
||||
team_id = str(getattr(event.source, "scope_id", "") or "")
|
||||
await self._remove_reaction(channel_id, ts, "eyes", team_id)
|
||||
if outcome == ProcessingOutcome.SUCCESS:
|
||||
await self._add_reaction(channel_id, ts, "white_check_mark")
|
||||
await self._add_reaction(channel_id, ts, "white_check_mark", team_id)
|
||||
elif outcome == ProcessingOutcome.FAILURE:
|
||||
await self._add_reaction(channel_id, ts, "x")
|
||||
await self._add_reaction(channel_id, ts, "x", team_id)
|
||||
|
||||
# ----- User identity resolution -----
|
||||
|
||||
|
|
@ -2298,7 +2319,9 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
response.raise_for_status()
|
||||
|
||||
thread_ts = self._resolve_thread_ts(reply_to, metadata)
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
result = await self._get_client(
|
||||
chat_id, team_id=self._metadata_team_id(metadata)
|
||||
).files_upload_v2(
|
||||
channel=chat_id,
|
||||
content=response.content,
|
||||
filename="image.png",
|
||||
|
|
@ -2374,7 +2397,9 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
last_exc = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
result = await self._get_client(
|
||||
chat_id, team_id=self._metadata_team_id(metadata)
|
||||
).files_upload_v2(
|
||||
channel=chat_id,
|
||||
file=video_path,
|
||||
filename=os.path.basename(video_path),
|
||||
|
|
@ -2434,7 +2459,9 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
last_exc = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
result = await self._get_client(
|
||||
chat_id, team_id=self._metadata_team_id(metadata)
|
||||
).files_upload_v2(
|
||||
channel=chat_id,
|
||||
file=file_path,
|
||||
filename=display_name,
|
||||
|
|
@ -2506,10 +2533,105 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
return None
|
||||
return (str(channel_id), str(thread_ts))
|
||||
|
||||
def _extract_assistant_thread_metadata(self, event: dict) -> Dict[str, str]:
|
||||
@staticmethod
|
||||
def _agent_view_context_key(team_id: str, user_id: str) -> Optional[Tuple[str, str]]:
|
||||
"""Return a per-workspace, per-user Agent-view context cache key."""
|
||||
if not team_id or not user_id:
|
||||
return None
|
||||
return (str(team_id), str(user_id))
|
||||
|
||||
def _cache_agent_view_context(self, metadata: Dict[str, str]) -> None:
|
||||
"""Remember a user's current Slack Agent-view context."""
|
||||
key = self._agent_view_context_key(
|
||||
metadata.get("team_id", ""), metadata.get("user_id", "")
|
||||
)
|
||||
if not key:
|
||||
return
|
||||
contexts = getattr(self, "_agent_view_contexts", None)
|
||||
if not isinstance(contexts, dict):
|
||||
contexts = {}
|
||||
self._agent_view_contexts = contexts
|
||||
contexts[key] = {
|
||||
field: value
|
||||
for field, value in metadata.items()
|
||||
if field in {"channel_id", "context_channel_id", "team_id", "user_id"}
|
||||
and value
|
||||
}
|
||||
max_contexts = getattr(self, "_AGENT_VIEW_CONTEXTS_MAX", 5000)
|
||||
if len(contexts) > max_contexts:
|
||||
excess = len(contexts) - max_contexts // 2
|
||||
for old_key in list(contexts)[:excess]:
|
||||
del contexts[old_key]
|
||||
|
||||
def _agent_view_context_for_event(
|
||||
self, event: dict, team_id: str, user_id: str
|
||||
) -> Dict[str, str]:
|
||||
"""Read Slack's inline Agent context, falling back to lifecycle state."""
|
||||
context = event.get("app_context") or event.get("context") or {}
|
||||
context_channel_id = self._context_channel_id(context)
|
||||
key = self._agent_view_context_key(team_id, user_id)
|
||||
contexts = getattr(self, "_agent_view_contexts", {})
|
||||
cached = contexts.get(key, {}) if isinstance(contexts, dict) and key else {}
|
||||
return {
|
||||
"context_channel_id": context_channel_id or cached.get("context_channel_id", ""),
|
||||
"team_id": team_id,
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _event_team_id(event: dict, body: Optional[dict] = None) -> str:
|
||||
"""Resolve a workspace ID from an event plus Bolt's outer payload.
|
||||
|
||||
Bolt injects only the inner ``event`` into an event listener, while
|
||||
Slack places ``team_id`` on the outer Events API payload. Reading both
|
||||
keeps multi-workspace client routing correct after a process boundary.
|
||||
"""
|
||||
for payload in (event, body or {}):
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
team = payload.get("team_id") or payload.get("team")
|
||||
if isinstance(team, str) and team:
|
||||
return team
|
||||
if isinstance(team, dict) and team.get("id"):
|
||||
return str(team["id"])
|
||||
authorizations = (body or {}).get("authorizations") if isinstance(body, dict) else None
|
||||
for authorization in authorizations or []:
|
||||
if isinstance(authorization, dict) and authorization.get("team_id"):
|
||||
return str(authorization["team_id"])
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _context_channel_id(context: Any) -> str:
|
||||
"""Extract the actively viewed channel from either Slack context shape."""
|
||||
if not isinstance(context, dict):
|
||||
return ""
|
||||
channel_id = context.get("channel_id")
|
||||
if channel_id:
|
||||
return str(channel_id)
|
||||
for entity in context.get("entities") or []:
|
||||
if not isinstance(entity, dict):
|
||||
continue
|
||||
value = entity.get("value")
|
||||
if isinstance(value, dict) and value.get("channel_id"):
|
||||
return str(value["channel_id"])
|
||||
if (
|
||||
isinstance(value, str)
|
||||
and str(entity.get("type") or "").endswith("channel_id")
|
||||
):
|
||||
return value
|
||||
return ""
|
||||
|
||||
def _extract_assistant_thread_metadata(
|
||||
self, event: dict, body: Optional[dict] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Extract Slack Assistant thread identity data from an event payload."""
|
||||
assistant_thread = event.get("assistant_thread") or {}
|
||||
context = assistant_thread.get("context") or event.get("context") or {}
|
||||
context = (
|
||||
assistant_thread.get("context")
|
||||
or event.get("app_context")
|
||||
or event.get("context")
|
||||
or {}
|
||||
)
|
||||
|
||||
channel_id = (
|
||||
assistant_thread.get("channel_id")
|
||||
|
|
@ -2529,13 +2651,10 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
or context.get("user_id")
|
||||
or ""
|
||||
)
|
||||
team_id = (
|
||||
event.get("team")
|
||||
or event.get("team_id")
|
||||
or assistant_thread.get("team_id")
|
||||
or ""
|
||||
team_id = self._event_team_id(event, body) or str(
|
||||
assistant_thread.get("team_id") or ""
|
||||
)
|
||||
context_channel_id = context.get("channel_id") or ""
|
||||
context_channel_id = self._context_channel_id(context)
|
||||
|
||||
return {
|
||||
"channel_id": str(channel_id) if channel_id else "",
|
||||
|
|
@ -2728,6 +2847,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
user_id=user_id,
|
||||
thread_id=thread_ts,
|
||||
chat_topic=metadata.get("context_channel_id") or None,
|
||||
scope_id=metadata.get("team_id") or None,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -2762,6 +2882,8 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
chat_name=channel_id,
|
||||
chat_type="dm",
|
||||
user_id=user_id,
|
||||
chat_topic=metadata.get("context_channel_id") or None,
|
||||
scope_id=metadata.get("team_id") or None,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -2773,9 +2895,11 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _handle_assistant_thread_lifecycle_event(self, event: dict) -> None:
|
||||
async def _handle_assistant_thread_lifecycle_event(
|
||||
self, event: dict, body: Optional[dict] = None
|
||||
) -> None:
|
||||
"""Handle Slack Assistant lifecycle events that carry user/thread identity."""
|
||||
metadata = self._extract_assistant_thread_metadata(event)
|
||||
metadata = self._extract_assistant_thread_metadata(event, body)
|
||||
self._cache_assistant_thread_metadata(metadata)
|
||||
self._seed_assistant_thread_session(metadata)
|
||||
await self._set_assistant_suggested_prompts(
|
||||
|
|
@ -2784,31 +2908,59 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
thread_ts=metadata.get("thread_ts", ""),
|
||||
)
|
||||
|
||||
async def _handle_app_home_opened(self, event: dict) -> None:
|
||||
"""Handle Slack Agent DM-open lifecycle events without producing replies."""
|
||||
if event.get("tab") != "messages":
|
||||
return
|
||||
|
||||
channel_id = event.get("channel") or event.get("channel_id") or ""
|
||||
async def _handle_app_context_changed(
|
||||
self, event: dict, body: Optional[dict] = None
|
||||
) -> None:
|
||||
"""Cache the current Agent-view context without entering the agent loop."""
|
||||
context = event.get("context") or event.get("app_context") or {}
|
||||
context_channel_id = self._context_channel_id(context)
|
||||
user_id = event.get("user") or event.get("user_id") or ""
|
||||
team_id = event.get("team") or event.get("team_id") or ""
|
||||
|
||||
if team_id and channel_id:
|
||||
self._channel_team[str(channel_id)] = str(team_id)
|
||||
|
||||
self._seed_agent_dm_session(
|
||||
team_id = self._event_team_id(event, body)
|
||||
# ``context_channel_id`` is a channel the user is viewing, not the DM
|
||||
# Hermes owns. Do not write it into _channel_team: channel IDs can be
|
||||
# shared across Slack Connect workspaces, so doing so can misroute a
|
||||
# later unrelated send. Workspace ownership is recorded from actual
|
||||
# inbound DM/channel events below.
|
||||
self._cache_agent_view_context(
|
||||
{
|
||||
"channel_id": str(channel_id) if channel_id else "",
|
||||
"context_channel_id": str(context_channel_id) if context_channel_id else "",
|
||||
"user_id": str(user_id) if user_id else "",
|
||||
"team_id": str(team_id) if team_id else "",
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_app_home_opened(
|
||||
self, event: dict, body: Optional[dict] = None
|
||||
) -> None:
|
||||
"""Handle Slack Agent DM-open lifecycle events without producing replies."""
|
||||
if event.get("tab") != "messages":
|
||||
return
|
||||
|
||||
context = event.get("context") or event.get("app_context") or {}
|
||||
channel_id = event.get("channel") or event.get("channel_id") or ""
|
||||
user_id = event.get("user") or event.get("user_id") or ""
|
||||
team_id = self._event_team_id(event, body)
|
||||
context_channel_id = self._context_channel_id(context)
|
||||
|
||||
if team_id and channel_id:
|
||||
self._channel_team[str(channel_id)] = str(team_id)
|
||||
|
||||
metadata = {
|
||||
"channel_id": str(channel_id) if channel_id else "",
|
||||
"user_id": str(user_id) if user_id else "",
|
||||
"team_id": str(team_id) if team_id else "",
|
||||
"context_channel_id": context_channel_id,
|
||||
}
|
||||
self._cache_agent_view_context(metadata)
|
||||
self._seed_agent_dm_session(metadata)
|
||||
await self._set_assistant_suggested_prompts(
|
||||
str(channel_id) if channel_id else "",
|
||||
team_id=str(team_id) if team_id else "",
|
||||
metadata["channel_id"],
|
||||
team_id=metadata["team_id"],
|
||||
)
|
||||
|
||||
async def _handle_slack_file_shared(self, event: dict) -> None:
|
||||
async def _handle_slack_file_shared(
|
||||
self, event: dict, body: Optional[dict] = None
|
||||
) -> None:
|
||||
"""Fallback for Slack file shares that do not arrive as message.files.
|
||||
|
||||
Slack documents ``file_shared`` as a file-ID-only event; callers must
|
||||
|
|
@ -2822,7 +2974,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
if not channel_id or not file_id:
|
||||
return
|
||||
|
||||
team_id = event.get("team_id") or event.get("team") or ""
|
||||
team_id = self._event_team_id(event, body)
|
||||
try:
|
||||
client = self._team_clients.get(team_id) if team_id else None
|
||||
info_resp = await (client or self._get_client(channel_id)).files_info(
|
||||
|
|
@ -2886,7 +3038,9 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
fallback_event["thread_ts"] = thread_ts
|
||||
await self._handle_slack_message(fallback_event)
|
||||
|
||||
async def _handle_slack_message(self, event: dict) -> None:
|
||||
async def _handle_slack_message(
|
||||
self, event: dict, payload: Optional[dict] = None
|
||||
) -> None:
|
||||
"""Handle an incoming Slack message event."""
|
||||
# Dedup: Slack Socket Mode can redeliver events after reconnects (#4777)
|
||||
event_ts = event.get("ts", "")
|
||||
|
|
@ -3046,10 +3200,9 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
user_id = event.get("user") or assistant_meta.get("user_id", "")
|
||||
if not channel_id:
|
||||
channel_id = assistant_meta.get("channel_id", "")
|
||||
team_id = (
|
||||
event.get("team")
|
||||
or event.get("team_id")
|
||||
or assistant_meta.get("team_id", "")
|
||||
team_id = self._event_team_id(event, payload) or assistant_meta.get("team_id", "")
|
||||
agent_context = self._agent_view_context_for_event(
|
||||
event, str(team_id or ""), str(user_id or "")
|
||||
)
|
||||
|
||||
# Track which workspace owns this channel
|
||||
|
|
@ -3159,6 +3312,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
channel_id=channel_id,
|
||||
thread_ts=event_thread_ts,
|
||||
user_id=user_id,
|
||||
team_id=team_id,
|
||||
)
|
||||
if (
|
||||
not reply_to_bot_thread
|
||||
|
|
@ -3189,6 +3343,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
channel_id=channel_id,
|
||||
thread_ts=event_thread_ts,
|
||||
user_id=user_id,
|
||||
team_id=team_id,
|
||||
):
|
||||
thread_context = await self._fetch_thread_context(
|
||||
channel_id=channel_id,
|
||||
|
|
@ -3220,9 +3375,9 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
if not file_id:
|
||||
continue
|
||||
try:
|
||||
info_resp = await self._get_client(channel_id).files_info(
|
||||
file=file_id
|
||||
)
|
||||
info_resp = await self._get_client(
|
||||
channel_id, team_id=team_id
|
||||
).files_info(file=file_id)
|
||||
if info_resp.get("ok"):
|
||||
f = info_resp["file"]
|
||||
else:
|
||||
|
|
@ -3479,6 +3634,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
thread_id=thread_ts,
|
||||
scope_id=str(team_id) if team_id else None,
|
||||
# Slack Workflow Builder / app posts arrive as
|
||||
# subtype=bot_message with user=None; flag them so the
|
||||
# gateway SLACK_ALLOW_BOTS bypass can authorize them
|
||||
|
|
@ -3549,6 +3705,18 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
if _should_react:
|
||||
self._reacting_message_ids.add(ts)
|
||||
|
||||
# App-context is per-turn, user-controlled Slack UI state. Surface it
|
||||
# with the inbound user message rather than storing it on SessionSource:
|
||||
# putting it in the cached system prompt would rebuild the agent whenever
|
||||
# the user switches views and would let stale context bleed into later
|
||||
# turns. The agent receives an inert label, never a fetched channel body.
|
||||
context_channel_id = agent_context.get("context_channel_id", "")
|
||||
if context_channel_id and context_channel_id != channel_id:
|
||||
msg_event.text = (
|
||||
f"[Slack app context: user is viewing channel {context_channel_id}]\n\n"
|
||||
f"{msg_event.text}"
|
||||
)
|
||||
|
||||
await self.handle_message(msg_event)
|
||||
|
||||
# ----- Approval button support (Block Kit) -----
|
||||
|
|
@ -3637,7 +3805,9 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
if thread_ts:
|
||||
kwargs["thread_ts"] = thread_ts
|
||||
|
||||
result = await self._get_client(chat_id).chat_postMessage(**kwargs)
|
||||
result = await self._get_client(
|
||||
chat_id, team_id=self._metadata_team_id(metadata)
|
||||
).chat_postMessage(**kwargs)
|
||||
msg_ts = result.get("ts", "")
|
||||
if msg_ts:
|
||||
self._approval_resolved[msg_ts] = False
|
||||
|
|
@ -3715,7 +3885,9 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
if thread_ts:
|
||||
kwargs["thread_ts"] = thread_ts
|
||||
|
||||
result = await self._get_client(chat_id).chat_postMessage(**kwargs)
|
||||
result = await self._get_client(
|
||||
chat_id, team_id=self._metadata_team_id(metadata)
|
||||
).chat_postMessage(**kwargs)
|
||||
return SendResult(
|
||||
success=True, message_id=result.get("ts", ""), raw_response=result
|
||||
)
|
||||
|
|
@ -3729,6 +3901,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
*,
|
||||
channel_id: str = "",
|
||||
user_name: Optional[str] = None,
|
||||
team_id: str = "",
|
||||
) -> bool:
|
||||
"""Return whether a Slack interactive caller may perform gated actions."""
|
||||
normalized_user_id = str(user_id or "").strip()
|
||||
|
|
@ -3747,6 +3920,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
chat_type="dm" if str(channel_id or "").startswith("D") else "group",
|
||||
user_id=normalized_user_id,
|
||||
user_name=str(user_name).strip() if user_name else None,
|
||||
scope_id=str(team_id) if team_id else None,
|
||||
)
|
||||
return bool(auth_fn(source))
|
||||
except Exception:
|
||||
|
|
@ -3776,6 +3950,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
"""Handle a slash-confirm button click from Block Kit."""
|
||||
await ack()
|
||||
|
||||
team_id = self._event_team_id({}, body)
|
||||
action_id = action.get("action_id", "")
|
||||
value = action.get("value", "")
|
||||
message = body.get("message", {})
|
||||
|
|
@ -3787,6 +3962,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
user_id,
|
||||
channel_id=channel_id,
|
||||
user_name=user_name,
|
||||
team_id=team_id,
|
||||
):
|
||||
logger.warning(
|
||||
"[Slack] Unauthorized slash-confirm click by %s (%s) - ignoring",
|
||||
|
|
@ -3851,7 +4027,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
]
|
||||
|
||||
try:
|
||||
await self._get_client(channel_id).chat_update(
|
||||
await self._get_client(channel_id, team_id=team_id or None).chat_update(
|
||||
channel=channel_id,
|
||||
ts=msg_ts,
|
||||
text=decision_text,
|
||||
|
|
@ -3876,7 +4052,9 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
thread_ts = message.get("thread_ts") or msg_ts
|
||||
if thread_ts:
|
||||
post_kwargs["thread_ts"] = thread_ts
|
||||
await self._get_client(channel_id).chat_postMessage(**post_kwargs)
|
||||
await self._get_client(
|
||||
channel_id, team_id=team_id or None
|
||||
).chat_postMessage(**post_kwargs)
|
||||
logger.info(
|
||||
"Slack button resolved slash-confirm for session %s (choice=%s, user=%s)",
|
||||
session_key,
|
||||
|
|
@ -3910,6 +4088,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
"""Handle an approval button click from Block Kit."""
|
||||
await ack()
|
||||
|
||||
team_id = self._event_team_id({}, body)
|
||||
action_id = action.get("action_id", "")
|
||||
session_key = action.get("value", "")
|
||||
message = body.get("message", {})
|
||||
|
|
@ -3922,6 +4101,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
user_id,
|
||||
channel_id=channel_id,
|
||||
user_name=user_name,
|
||||
team_id=team_id,
|
||||
):
|
||||
logger.warning(
|
||||
"[Slack] Unauthorized approval click by %s (%s) - ignoring",
|
||||
|
|
@ -3989,7 +4169,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
]
|
||||
|
||||
try:
|
||||
await self._get_client(channel_id).chat_update(
|
||||
await self._get_client(channel_id, team_id=team_id or None).chat_update(
|
||||
channel=channel_id,
|
||||
ts=msg_ts,
|
||||
text=decision_text,
|
||||
|
|
@ -4049,7 +4229,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
return cached.content
|
||||
|
||||
try:
|
||||
client = self._get_client(channel_id)
|
||||
client = self._get_client(channel_id, team_id=team_id)
|
||||
|
||||
# Retry with exponential backoff for Tier-3 rate limits (429).
|
||||
result = None
|
||||
|
|
@ -4210,7 +4390,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
return cached.parent_text
|
||||
|
||||
try:
|
||||
client = self._get_client(channel_id)
|
||||
client = self._get_client(channel_id, team_id=team_id)
|
||||
result = await client.conversations_replies(
|
||||
channel=channel_id,
|
||||
ts=thread_ts,
|
||||
|
|
@ -4293,6 +4473,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
chat_id=channel_id,
|
||||
chat_type="dm" if is_dm else "group",
|
||||
user_id=user_id,
|
||||
scope_id=team_id or None,
|
||||
)
|
||||
|
||||
event = MessageEvent(
|
||||
|
|
@ -4330,6 +4511,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
channel_id: str,
|
||||
thread_ts: str,
|
||||
user_id: str,
|
||||
team_id: str = "",
|
||||
) -> bool:
|
||||
"""Check if there's an active session for a thread.
|
||||
|
||||
|
|
@ -4354,6 +4536,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
chat_type="group",
|
||||
user_id=user_id,
|
||||
thread_id=thread_ts,
|
||||
scope_id=team_id or None,
|
||||
)
|
||||
|
||||
# Read session isolation settings from the store's config
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ AUTHOR_MAP = {
|
|||
"neo@neodeMac-mini.local": "neo-claw-bot", # PR #58465 salvage (moa: drop empty user turns from advisory view)
|
||||
"m.guttmann@journaway.com": "mguttmann", # PR #63738 salvage (Anthropic setup-token pool auth normalization)
|
||||
"VrtxOmega@pm.me": "VrtxOmega", # PR #43809 salvage (desktop: WSL folder-picker path bridge)
|
||||
"gn00742754@gmail.com": "SemonCat", # PR #56786 salvage (Slack Agent View manifests and Assistant APIs)
|
||||
"jake.long.vu@vucar.net": "jakelongvu-bot", # PR #36683 partial salvage (approval: honor canonical approvals.timeout in gateway waits)
|
||||
"luigi@users.noreply.github.com": "Tortugasaur", # PR #43205 salvage (desktop: profile-aware three-way approval mode statusbar control)
|
||||
"kavi@local.hermes": "kavioavio", # Issue #46544 / PR #47705 evolution (smart DENY exact-operation owner override)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ class DummyTelegramAdapter(BasePlatformAdapter):
|
|||
self.typing.append({"chat_id": chat_id, "metadata": metadata})
|
||||
return None
|
||||
|
||||
async def stop_typing(self, chat_id: str, metadata=None) -> None:
|
||||
self.typing.append({"chat_id": chat_id, "stopped": True, "metadata": metadata})
|
||||
|
||||
async def get_chat_info(self, chat_id: str):
|
||||
return {"id": chat_id}
|
||||
|
||||
|
|
@ -143,6 +146,11 @@ class TestBasePlatformTopicSessions:
|
|||
"metadata": {"thread_id": "17585"},
|
||||
}
|
||||
]
|
||||
assert {
|
||||
"chat_id": "-1001",
|
||||
"stopped": True,
|
||||
"metadata": {"thread_id": "17585"},
|
||||
} in adapter.typing
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", ProcessingOutcome.SUCCESS),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from unittest.mock import AsyncMock, MagicMock, patch, call
|
|||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.platforms.base import (
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
|
|
@ -148,6 +149,7 @@ class TestSlashCommandSessionIsolation:
|
|||
assert event.source.chat_type == "group"
|
||||
assert event.source.chat_id == "C123"
|
||||
assert event.source.user_id == "U123"
|
||||
assert event.source.scope_id == "T123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_slash_command_keeps_dm_session_semantics(self, adapter):
|
||||
|
|
@ -165,6 +167,7 @@ class TestSlashCommandSessionIsolation:
|
|||
assert event.source.chat_type == "dm"
|
||||
assert event.source.chat_id == "D123"
|
||||
assert event.source.user_id == "U123"
|
||||
assert event.source.scope_id == "T123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -239,6 +242,7 @@ class TestAppMentionHandler:
|
|||
assert "message" in registered_events
|
||||
assert "app_mention" in registered_events
|
||||
assert "app_home_opened" in registered_events
|
||||
assert "app_context_changed" in registered_events
|
||||
assert "reaction_added" in registered_events
|
||||
assert "reaction_removed" in registered_events
|
||||
assert "assistant_thread_started" in registered_events
|
||||
|
|
@ -917,6 +921,25 @@ class TestSendDocument:
|
|||
assert call_kwargs["filename"] == "report.pdf"
|
||||
assert call_kwargs["initial_comment"] == "Here's the report"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_document_uses_metadata_workspace_client(self, adapter, tmp_path):
|
||||
"""Outbound media follows the inbound Slack workspace across gateway boundaries."""
|
||||
test_file = tmp_path / "report.pdf"
|
||||
test_file.write_bytes(b"%PDF-1.4 fake content")
|
||||
secondary_client = AsyncMock()
|
||||
secondary_client.files_upload_v2 = AsyncMock(return_value={"ok": True})
|
||||
adapter._team_clients["T_SECONDARY"] = secondary_client
|
||||
|
||||
result = await adapter.send_document(
|
||||
chat_id="C123",
|
||||
file_path=str(test_file),
|
||||
metadata={"slack_team_id": "T_SECONDARY"},
|
||||
)
|
||||
|
||||
assert result.success
|
||||
secondary_client.files_upload_v2.assert_awaited_once()
|
||||
adapter._app.client.files_upload_v2.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_document_custom_name(self, adapter, tmp_path):
|
||||
test_file = tmp_path / "data.csv"
|
||||
|
|
@ -2218,6 +2241,23 @@ class TestSendTyping:
|
|||
"team_id": "",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_with_metadata_preserves_sibling_status(self, adapter):
|
||||
adapter._app.client.assistant_threads_setStatus = AsyncMock()
|
||||
await adapter.send_typing("D123", metadata={"thread_id": "thread_a"})
|
||||
await adapter.send_typing("D123", metadata={"thread_id": "thread_b"})
|
||||
|
||||
await adapter._stop_typing_with_metadata(
|
||||
"D123", {"thread_id": "thread_a"}
|
||||
)
|
||||
|
||||
assert adapter._app.client.assistant_threads_setStatus.call_args_list == [
|
||||
call(channel_id="D123", thread_ts="thread_a", status="is thinking..."),
|
||||
call(channel_id="D123", thread_ts="thread_b", status="is thinking..."),
|
||||
call(channel_id="D123", thread_ts="thread_a", status=""),
|
||||
]
|
||||
assert ("D123", "thread_b") in adapter._active_status_threads
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_final_edit_uses_workspace_client_from_metadata(
|
||||
self, adapter
|
||||
|
|
@ -3263,6 +3303,99 @@ class TestAssistantThreadLifecycle:
|
|||
thread_ts="171.000",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_view_context_is_scoped_per_workspace_and_user(
|
||||
self, assistant_adapter
|
||||
):
|
||||
await assistant_adapter._handle_app_context_changed(
|
||||
{
|
||||
"type": "app_context_changed",
|
||||
"user": "U_ONE",
|
||||
"context": {
|
||||
"entities": [
|
||||
{
|
||||
"type": "slack#/types/channel_id",
|
||||
"value": "C_CONTEXT_ONE",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
{"team_id": "T_ONE"},
|
||||
)
|
||||
await assistant_adapter._handle_app_context_changed(
|
||||
{
|
||||
"type": "app_context_changed",
|
||||
"user": "U_TWO",
|
||||
"context": {
|
||||
"entities": [
|
||||
{
|
||||
"type": "slack#/types/channel_id",
|
||||
"value": "C_CONTEXT_TWO",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
{"team_id": "T_TWO"},
|
||||
)
|
||||
|
||||
assert assistant_adapter._agent_view_context_for_event(
|
||||
{}, "T_ONE", "U_ONE"
|
||||
)["context_channel_id"] == "C_CONTEXT_ONE"
|
||||
assert assistant_adapter._agent_view_context_for_event(
|
||||
{}, "T_TWO", "U_TWO"
|
||||
)["context_channel_id"] == "C_CONTEXT_TWO"
|
||||
assert "C_CONTEXT_ONE" not in assistant_adapter._channel_team
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_view_message_preserves_outer_team_and_turn_context(
|
||||
self, assistant_adapter
|
||||
):
|
||||
assistant_adapter._app.client.users_info = AsyncMock(
|
||||
return_value={"user": {"profile": {"display_name": "Tyler"}}}
|
||||
)
|
||||
assistant_adapter._app.client.reactions_add = AsyncMock()
|
||||
assistant_adapter._app.client.reactions_remove = AsyncMock()
|
||||
await assistant_adapter._handle_app_context_changed(
|
||||
{
|
||||
"type": "app_context_changed",
|
||||
"user": "U_USER",
|
||||
"context": {
|
||||
"entities": [
|
||||
{
|
||||
"type": "slack#/types/channel_id",
|
||||
"value": "C_ACTIVE",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
{"team_id": "T_OTHER"},
|
||||
)
|
||||
|
||||
await assistant_adapter._handle_slack_message(
|
||||
{
|
||||
"text": "help me plan",
|
||||
"channel": "D123",
|
||||
"channel_type": "im",
|
||||
"ts": "171.111",
|
||||
"user": "U_USER",
|
||||
},
|
||||
{"team_id": "T_OTHER"},
|
||||
)
|
||||
|
||||
msg_event = assistant_adapter.handle_message.await_args.args[0]
|
||||
assert msg_event.source.scope_id == "T_OTHER"
|
||||
assert msg_event.metadata["slack_team_id"] == "T_OTHER"
|
||||
assert msg_event.source.thread_id == "171.111"
|
||||
assert msg_event.text.startswith(
|
||||
"[Slack app context: user is viewing channel C_ACTIVE]"
|
||||
)
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
assert runner._thread_metadata_for_source(msg_event.source) == {
|
||||
"thread_id": "171.111",
|
||||
"slack_team_id": "T_OTHER",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_message_sets_assistant_thread_title_once(
|
||||
self, assistant_adapter
|
||||
|
|
@ -4344,6 +4477,27 @@ class TestThreadContextUnverifiedTagging:
|
|||
assert "identity hasn't" not in content
|
||||
assert "[Thread context — prior messages in this thread (not yet in conversation history):]" in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_context_uses_workspace_client(self, adapter):
|
||||
team_client = AsyncMock()
|
||||
team_client.conversations_replies = self._make_replies(self._thread_messages())
|
||||
adapter._team_clients["T_OTHER"] = team_client
|
||||
adapter._thread_context_cache.clear()
|
||||
|
||||
with patch.object(
|
||||
adapter, "_resolve_user_name",
|
||||
new=AsyncMock(side_effect=lambda uid, **_: uid),
|
||||
):
|
||||
await adapter._fetch_thread_context(
|
||||
channel_id="C1",
|
||||
thread_ts="100.0",
|
||||
current_ts="999.0",
|
||||
team_id="T_OTHER",
|
||||
)
|
||||
|
||||
team_client.conversations_replies.assert_awaited_once()
|
||||
adapter._app.client.conversations_replies.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_authorized_no_tags(self, adapter):
|
||||
"""Auth callback returning True for every sender → no [unverified] tags."""
|
||||
|
|
|
|||
|
|
@ -323,6 +323,18 @@ class TestSlackInteractiveAuth:
|
|||
assert runner.seen_sources[0].chat_id == "C1"
|
||||
assert runner.seen_sources[0].chat_type == "group"
|
||||
|
||||
def test_passes_workspace_scope_to_gateway_runner_auth(self):
|
||||
adapter = _make_adapter()
|
||||
runner = _attach_auth_runner(adapter)
|
||||
|
||||
assert adapter._is_interactive_user_authorized(
|
||||
"U_OK",
|
||||
channel_id="C1",
|
||||
user_name="operator",
|
||||
team_id="T1",
|
||||
) is True
|
||||
assert runner.seen_sources[0].scope_id == "T1"
|
||||
|
||||
|
||||
class TestSlackSlashConfirmAction:
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -364,6 +376,40 @@ class TestSlackSlashConfirmAction:
|
|||
mock_client.chat_update.assert_called_once()
|
||||
mock_client.chat_postMessage.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_uses_outer_payload_workspace_client(self, monkeypatch):
|
||||
adapter = _make_adapter()
|
||||
secondary_client = AsyncMock()
|
||||
adapter._team_clients["T2"] = secondary_client
|
||||
monkeypatch.delenv("SLACK_ALLOWED_USERS", raising=False)
|
||||
monkeypatch.delenv("SLACK_ALLOW_ALL_USERS", raising=False)
|
||||
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
|
||||
monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "U_OWNER")
|
||||
|
||||
ack = AsyncMock()
|
||||
body = {
|
||||
"team_id": "T2",
|
||||
"message": {
|
||||
"ts": "2222.3333",
|
||||
"blocks": [
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": "Original prompt"}},
|
||||
],
|
||||
},
|
||||
"channel": {"id": "C1"},
|
||||
"user": {"name": "owner", "id": "U_OWNER"},
|
||||
}
|
||||
action = {
|
||||
"action_id": "hermes_confirm_once",
|
||||
"value": "agent:main:slack:group:C1:1111|confirm-1",
|
||||
}
|
||||
|
||||
with patch("tools.slash_confirm.resolve", new=AsyncMock(return_value="follow-up")):
|
||||
await adapter._handle_slash_confirm_action(ack, body, action)
|
||||
|
||||
secondary_client.chat_update.assert_awaited_once()
|
||||
secondary_client.chat_postMessage.assert_awaited_once()
|
||||
adapter._team_clients["T1"].chat_update.assert_not_called()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _fetch_thread_context
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ class TestSlackFullManifest:
|
|||
|
||||
bot_events = manifest["settings"]["event_subscriptions"]["bot_events"]
|
||||
assert "app_home_opened" in bot_events
|
||||
assert "app_context_changed" in bot_events
|
||||
assert "message.im" in bot_events
|
||||
assert "assistant_thread_started" not in bot_events
|
||||
assert "assistant_thread_context_changed" not in bot_events
|
||||
|
|
|
|||
|
|
@ -36,12 +36,13 @@ Mode — all at once.
|
|||
|
||||
### Option A: From a Hermes-generated manifest (recommended)
|
||||
|
||||
1. Generate the manifest:
|
||||
1. Generate the manifest. New Slack apps must use Agent view:
|
||||
```bash
|
||||
hermes slack manifest --write
|
||||
hermes slack manifest --agent-view --write
|
||||
```
|
||||
This writes `~/.hermes/slack-manifest.json` and prints paste-in
|
||||
instructions.
|
||||
instructions. Existing apps that still use Slack's legacy Assistant view
|
||||
can omit `--agent-view` until they are ready to migrate.
|
||||
2. Go to [https://api.slack.com/apps](https://api.slack.com/apps) →
|
||||
**Create New App** → **From an app manifest**
|
||||
3. Pick your workspace, paste the JSON contents, review, click **Next**
|
||||
|
|
@ -243,6 +244,23 @@ Step 1, Option A) that declares every command in
|
|||
as a slash command. In Socket Mode, Slack routes the command event
|
||||
through the WebSocket regardless of the manifest's `url` field.
|
||||
|
||||
### Agent messaging experience
|
||||
|
||||
New Slack apps use Slack's **Agent** messaging experience. Existing Hermes
|
||||
Assistant apps can migrate by regenerating the manifest with `--agent-view`:
|
||||
|
||||
```bash
|
||||
hermes slack manifest --agent-view --write
|
||||
```
|
||||
|
||||
Update the manifest in **Features → App Manifest**, then reinstall the app if
|
||||
Slack asks. Agent view cannot be reverted to Assistant view, and users may need
|
||||
to hard-refresh Slack after the switch. The generated Agent manifest subscribes
|
||||
to `message.im`, `app_home_opened`, and `app_context_changed`, so Hermes can
|
||||
identify a Messages-tab DM and receive the user's active Slack context with a
|
||||
turn. Hermes only supplies that context as a label; it does not read the viewed
|
||||
channel's history.
|
||||
|
||||
### Refreshing slash commands after updates
|
||||
|
||||
When Hermes adds new commands (e.g. after `hermes update`), regenerate
|
||||
|
|
@ -353,6 +371,19 @@ platforms:
|
|||
# gracefully fall back to aligned monospace.
|
||||
rich_blocks: false
|
||||
|
||||
# Append Slack-native feedback controls to final Block Kit replies.
|
||||
# Requires rich_blocks: true. Default: false.
|
||||
feedback_buttons: false
|
||||
|
||||
# Suggested prompts pinned at the top of Agent view's Messages tab.
|
||||
# Either a list of {title, message} rows, or a titled object:
|
||||
# {title: "Start here", prompts: [{title: "Plan", message: "..."}]}
|
||||
suggested_prompts: []
|
||||
|
||||
# Title Agent/Assistant DM threads from the first user message.
|
||||
# Default: true. Set false to leave Slack's default thread titles.
|
||||
assistant_thread_titles: true
|
||||
|
||||
# Continuable-cron delivery surface (default: "thread").
|
||||
# "in_channel" delivers a continuable cron job FLAT into the channel
|
||||
# (no dedicated thread); pair with reply_in_thread: false (and
|
||||
|
|
@ -367,6 +398,9 @@ platforms:
|
|||
| `platforms.slack.extra.reply_in_thread` | `true` | When `false`, channel messages get direct replies instead of threads. Messages inside existing threads still reply in-thread. |
|
||||
| `platforms.slack.extra.reply_broadcast` | `false` | When `true`, thread replies are also posted to the main channel. Only the first chunk is broadcast. |
|
||||
| `platforms.slack.extra.rich_blocks` | `false` | When `true`, agent messages are rendered as [Block Kit](https://docs.slack.dev/block-kit/) blocks (headers, dividers, true nested lists, and native tables). A plain-text fallback is always sent. Tables over Slack's limits fall back to aligned monospace. No app reinstall required — it's a send-side change only. |
|
||||
| `platforms.slack.extra.feedback_buttons` | `false` | When `true` with `rich_blocks`, appends Slack-native feedback controls to final replies. |
|
||||
| `platforms.slack.extra.suggested_prompts` | `[]` | Up to four `{title, message}` prompts for Agent/Assistant DM entry points; accepts either a list or `{title, prompts}`. |
|
||||
| `platforms.slack.extra.assistant_thread_titles` | `true` | When `true`, names Agent/Assistant DM threads from the first user message. |
|
||||
| `platforms.slack.extra.cron_continuable_surface` | `"thread"` | Delivery surface for [continuable cron jobs](../features/cron.md#flat-in-channel-continuation-slack). `"thread"` opens a dedicated thread per delivery (default); `"in_channel"` delivers flat into the channel timeline. Pair `in_channel` with `reply_in_thread: false` (and `require_mention: false`) so a plain channel reply continues the job. |
|
||||
|
||||
### Session Isolation
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue