Add Handbook.md benchmark tasks
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# Slack Capabilities
|
||||
|
||||
A mock Slack workspace with channels, messages, threads, reactions, pins, user profiles, file sharing, direct messages, and presence/status. Supports an admin mode (via `is_admin: true` in state) where the agent can edit/delete any user's messages and manage channels.
|
||||
|
||||
## What the agent can do
|
||||
|
||||
**Send and manage messages.** Post to channels, reply to threads, edit existing messages, delete messages. Cross-channel search by text + author with pagination (`search_messages`). Search supports `in:`, `from:`, `after:`, `before:`, `during:`, and `has:link|reaction|star|pin`; `from:me` is not supported because the mock has no caller identity.
|
||||
|
||||
**Read conversations.** Get channel message history, view full thread replies, browse messages. `search_messages` finds across all channels simultaneously.
|
||||
|
||||
**Manage channels.** List all channels, create public or private channels, archive, rename (with history tracking), set channel topics and purposes.
|
||||
|
||||
**Reactions and pins.** Add emoji reactions to messages, pin important messages for easy reference, unpin, list all pinned messages in a channel.
|
||||
|
||||
**User profiles and presence.** Look up profiles (name, title, email), check if a user is online or away, and set custom status text and emoji.
|
||||
|
||||
**File sharing.** Upload files to channels (with automatic MIME type detection) and list files shared in a channel. Uploaded files create a message in the channel with the file attached.
|
||||
|
||||
**Direct messages.** Open a DM with another user, list existing DM conversations, and send DMs. DMs are separate from channel messages.
|
||||
|
||||
**Admin mode.** When `is_admin: true` in state, the mock user is treated as a workspace admin: `edit_message` and `delete_message` can act on any user's messages (not just the bot's own), and `archive_channel` / `rename_channel` / `set_channel_topic` work without membership restrictions.
|
||||
|
||||
## Coverage gaps
|
||||
|
||||
- No channel membership management (join/leave/invite)
|
||||
- No message scheduling or reminders
|
||||
- No Slack apps, bots, or integrations
|
||||
- No message formatting (Block Kit) — only plain text
|
||||
- No custom emoji management
|
||||
- No channel notification settings
|
||||
- `search_messages` returns `channel_scope_conflict` when `channel_id` and query `in:#channel` point to different channels
|
||||
|
||||
## Toolsets
|
||||
|
||||
27 tools total: 25 model-facing tools plus 2 state tools. Toolsets map to `WORLDBENCH_TOOL_SETS` values (prefixed form — e.g., `slack_messages`).
|
||||
|
||||
| Toolset | Tools | Description |
|
||||
|---------|-------|-------------|
|
||||
| `all` / `slack_all` | 25 | All model-facing tools |
|
||||
| `read` / `slack_read` | 10 | Model-facing read-only tools |
|
||||
| `write` / `slack_write` | 15 | Model-facing write tools |
|
||||
| `slack_messages` | 7 | Core messaging: post, reply, edit, delete, history, threads, search |
|
||||
| `slack_channels` | 5 | Channel management: list, create, archive, rename, set topic |
|
||||
| `slack_reactions_pins` | 4 | Reactions + pins: add reaction, pin/unpin, list pins |
|
||||
| `slack_users` | 4 | User info: get users, get profile, get presence, set status |
|
||||
| `slack_files` | 2 | File sharing: upload, list |
|
||||
| `slack_dms` | 3 | Direct messages: open DM, list DMs, send DM |
|
||||
| `slack_admin` | 3 | Admin-only operations: archive channel, rename channel, set topic (paired with `is_admin: true`) |
|
||||
| `slack_core` | 11 | Baseline chat plus legacy Toolathlon reactions/profile tools |
|
||||
| `slack_toolathlon_legacy` | 8 | Legacy Toolathlon tool subset (pre-integration) |
|
||||
| `slack_state` | 2 | `export_state`, `import_state` for fixture seeding and grading |
|
||||
|
||||
**Admin mode.** Set `is_admin: true` in the world's Slack state JSON to let the agent edit/delete any user's messages and perform channel-admin operations. Default `false` restricts writes to the bot user's own messages.
|
||||
|
||||
**Bot user ID.** Default `U_MOCK_BOT`. Override per world by setting `bot_user_id` in the state JSON — the value round-trips through `export_state` / `import_state`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Slack Mock MCP Server
|
||||
|
||||
A Python mock Slack MCP server for testing and development. This server provides offline Slack-like tools backed by JSON state and Pydantic validation.
|
||||
|
||||
## Tools
|
||||
|
||||
The server exposes tools for:
|
||||
|
||||
- channels and direct messages
|
||||
- messages and thread replies
|
||||
- search
|
||||
- reactions and pins
|
||||
- users, profiles, presence, and status
|
||||
- file upload/listing
|
||||
- state import/export for fixture setup and grading
|
||||
|
||||
## Usage
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
uv run --package slack-mock python -m slack_mock
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
uv run --package slack-mock pytest packages/slack/tests
|
||||
```
|
||||
|
||||
### MCP Configuration
|
||||
|
||||
`packages/slack/mcp.json` runs the server through `uv run --package slack-mock python -m slack_mock`.
|
||||
|
||||
## Notes
|
||||
|
||||
This is a fully offline mock server. No actual Slack API calls are made.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"run": {
|
||||
"command": "python",
|
||||
"args": [
|
||||
"-m",
|
||||
"slack_mock"
|
||||
]
|
||||
},
|
||||
"toolsets": {
|
||||
"read": [
|
||||
"get_channel_history",
|
||||
"get_thread_replies",
|
||||
"get_user_presence",
|
||||
"get_user_profile",
|
||||
"get_users",
|
||||
"list_channels",
|
||||
"list_dms",
|
||||
"list_files",
|
||||
"list_pins",
|
||||
"list_workspaces",
|
||||
"search_messages"
|
||||
],
|
||||
"write": [
|
||||
"add_reaction",
|
||||
"archive_channel",
|
||||
"create_channel",
|
||||
"delete_message",
|
||||
"edit_message",
|
||||
"open_dm",
|
||||
"open_mpim",
|
||||
"pin_message",
|
||||
"post_message",
|
||||
"rename_channel",
|
||||
"reply_to_thread",
|
||||
"send_dm",
|
||||
"send_mpim",
|
||||
"set_channel_topic",
|
||||
"set_user_status",
|
||||
"unpin_message",
|
||||
"upload_file"
|
||||
],
|
||||
"messages": [
|
||||
"delete_message",
|
||||
"edit_message",
|
||||
"get_channel_history",
|
||||
"get_thread_replies",
|
||||
"post_message",
|
||||
"reply_to_thread",
|
||||
"search_messages"
|
||||
],
|
||||
"channels": [
|
||||
"archive_channel",
|
||||
"create_channel",
|
||||
"list_channels",
|
||||
"rename_channel",
|
||||
"set_channel_topic"
|
||||
],
|
||||
"reactions_pins": [
|
||||
"add_reaction",
|
||||
"list_pins",
|
||||
"pin_message",
|
||||
"unpin_message"
|
||||
],
|
||||
"users": [
|
||||
"get_user_presence",
|
||||
"get_user_profile",
|
||||
"get_users",
|
||||
"set_user_status"
|
||||
],
|
||||
"files": [
|
||||
"list_files",
|
||||
"upload_file"
|
||||
],
|
||||
"dms": [
|
||||
"list_dms",
|
||||
"open_dm",
|
||||
"open_mpim",
|
||||
"send_dm",
|
||||
"send_mpim"
|
||||
],
|
||||
"workspaces": [
|
||||
"list_workspaces"
|
||||
],
|
||||
"admin": [
|
||||
"archive_channel",
|
||||
"rename_channel",
|
||||
"set_channel_topic"
|
||||
],
|
||||
"core": [
|
||||
"get_channel_history",
|
||||
"get_thread_replies",
|
||||
"get_users",
|
||||
"list_channels",
|
||||
"list_dms",
|
||||
"post_message",
|
||||
"reply_to_thread",
|
||||
"search_messages",
|
||||
"send_dm",
|
||||
"send_mpim",
|
||||
"add_reaction",
|
||||
"get_user_profile"
|
||||
],
|
||||
"toolathlon_legacy": [
|
||||
"add_reaction",
|
||||
"get_channel_history",
|
||||
"get_thread_replies",
|
||||
"get_user_profile",
|
||||
"get_users",
|
||||
"list_channels",
|
||||
"post_message",
|
||||
"reply_to_thread"
|
||||
],
|
||||
"state": [
|
||||
"export_state",
|
||||
"import_state"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
[build-system]
|
||||
build-backend = "uv_build"
|
||||
requires = [ "uv-build>=0.11,<0.12" ]
|
||||
|
||||
[project]
|
||||
name = "slack-mock"
|
||||
version = "0.1.0"
|
||||
description = "Slack mock MCP server"
|
||||
requires-python = ">=3.13"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"fastmcp>=3,<4",
|
||||
"pydantic>=2.12.5",
|
||||
"starlette>=0.50",
|
||||
"uvicorn>=0.38",
|
||||
]
|
||||
scripts.slack-mock = "slack_mock:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=9.0.2",
|
||||
"pytest-asyncio>=1.3",
|
||||
"ruff>=0.14.14",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
line-length = 120
|
||||
lint.select = [ "B", "E", "F", "I", "PLW", "SIM", "UP" ]
|
||||
lint.ignore = [ "E501", "PLW0603" ]
|
||||
lint.isort.known-first-party = [ "slack_mock" ]
|
||||
|
||||
[tool.pytest]
|
||||
ini_options.testpaths = [ "tests" ]
|
||||
ini_options.asyncio_mode = "auto"
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Slack mock MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
from .async_tool_guard import assert_tools_async
|
||||
from .server import mcp
|
||||
from .state import init_state
|
||||
|
||||
__all__ = ["main", "mcp"]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
assert_tools_async(mcp)
|
||||
|
||||
parser = argparse.ArgumentParser(description="Slack mock MCP Server")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||
|
||||
init_state()
|
||||
|
||||
port = os.environ.get("PORT")
|
||||
if port:
|
||||
from slack_mock.viewer import run_http_server
|
||||
|
||||
run_http_server(mcp, int(port))
|
||||
else:
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Entry point for ``python -m slack_mock``."""
|
||||
|
||||
from . import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Boot-time guard: every registered MCP tool must be async.
|
||||
|
||||
Sync (`def`) tools make FastMCP run pydantic argument validation in an anyio
|
||||
worker threadpool. Under concurrent calls the shared pydantic-core validator is
|
||||
re-entered across threads and panics with ``pyo3_runtime.PanicException:
|
||||
dictionary changed size during iteration``, which tears down the
|
||||
StreamableHTTP task group and turns every later request into a 500. Async
|
||||
(`async def`) tools validate on the single-threaded event loop and are safe.
|
||||
|
||||
This guard runs at server boot (and therefore during ``mcp-proxy gen``, which
|
||||
boots every server): a non-conformant package fails fast instead of shipping a
|
||||
server that can crash under load. The same check is intentionally duplicated in
|
||||
each package because the bundled prod images install only that package's own
|
||||
dependencies, so there is no shared runtime module to import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _unwrap(fn: Any) -> Any:
|
||||
while isinstance(fn, partial):
|
||||
fn = fn.func
|
||||
return fn
|
||||
|
||||
|
||||
def find_sync_tools(mcp: Any) -> list[str]:
|
||||
"""Return the names of registered tools whose function is not a coroutine."""
|
||||
# mcp.server.fastmcp.FastMCP exposes a synchronous tool manager.
|
||||
manager = getattr(mcp, "_tool_manager", None)
|
||||
if manager is not None and hasattr(manager, "list_tools"):
|
||||
return sorted(t.name for t in manager.list_tools() if not inspect.iscoroutinefunction(_unwrap(t.fn)))
|
||||
|
||||
# fastmcp.FastMCP exposes an async API.
|
||||
async def _collect() -> list[str]:
|
||||
sync: list[str] = []
|
||||
for descriptor in await mcp.list_tools():
|
||||
tool = await mcp.get_tool(descriptor.name)
|
||||
if not inspect.iscoroutinefunction(_unwrap(tool.fn)):
|
||||
sync.append(descriptor.name)
|
||||
return sorted(sync)
|
||||
|
||||
return asyncio.run(_collect())
|
||||
|
||||
|
||||
def assert_tools_async(mcp: Any) -> None:
|
||||
"""Raise if any registered tool is synchronous."""
|
||||
sync = find_sync_tools(mcp)
|
||||
if sync:
|
||||
raise RuntimeError(
|
||||
"MCP tools must be async (`async def`). Sync tools run pydantic argument "
|
||||
"validation in a worker threadpool and can trigger a pydantic-core "
|
||||
"'dictionary changed size during iteration' panic under concurrent calls, "
|
||||
"which kills the server. Make these tools async: " + ", ".join(sync)
|
||||
)
|
||||
@@ -0,0 +1,764 @@
|
||||
"""Pydantic models for Slack state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Any, Literal, Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator, model_validator
|
||||
|
||||
|
||||
class StrictStateModel(BaseModel):
|
||||
"""Base model for persisted Slack state."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
NonEmptyString = Annotated[str, StringConstraints(min_length=1)]
|
||||
SlackChannelId = Annotated[str, StringConstraints(pattern=r"^[CDG][A-Za-z0-9_]+$")]
|
||||
SlackFileId = Annotated[str, StringConstraints(pattern=r"^F[A-Za-z0-9_]+$")]
|
||||
SlackTeamId = Annotated[str, StringConstraints(pattern=r"^T[A-Za-z0-9_]+$")]
|
||||
SlackUserId = Annotated[str, StringConstraints(pattern=r"^[UW][A-Za-z0-9_]+$")]
|
||||
SlackTs = Annotated[str, StringConstraints(pattern=r"^\d{10}\.\d{3,6}$")]
|
||||
SlackStatusEmoji = Annotated[str, StringConstraints(pattern=r"^$|^:[a-z0-9_+-]+:$")]
|
||||
SlackWorkspaceId = NonEmptyString
|
||||
|
||||
|
||||
def _non_empty(value: str, field_name: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError(f"{field_name} must be non-empty")
|
||||
return value
|
||||
|
||||
|
||||
class SlackMessageType(StrEnum):
|
||||
MESSAGE = "message"
|
||||
|
||||
|
||||
class SlackMessageSubtype(StrEnum):
|
||||
BOT_MESSAGE = "bot_message"
|
||||
CHANNEL_ARCHIVE = "channel_archive"
|
||||
CHANNEL_JOIN = "channel_join"
|
||||
CHANNEL_LEAVE = "channel_leave"
|
||||
CHANNEL_NAME = "channel_name"
|
||||
CHANNEL_PURPOSE = "channel_purpose"
|
||||
CHANNEL_TOPIC = "channel_topic"
|
||||
CHANNEL_UNARCHIVE = "channel_unarchive"
|
||||
FILE_SHARE = "file_share"
|
||||
HUDDLE_THREAD = "huddle_thread"
|
||||
ME_MESSAGE = "me_message"
|
||||
MESSAGE_CHANGED = "message_changed"
|
||||
MESSAGE_DELETED = "message_deleted"
|
||||
THREAD_BROADCAST = "thread_broadcast"
|
||||
|
||||
|
||||
class SlackTwoFactorType(StrEnum):
|
||||
APP = "app"
|
||||
SMS = "sms"
|
||||
|
||||
|
||||
class SlackFileMode(StrEnum):
|
||||
HOSTED = "hosted"
|
||||
EXTERNAL = "external"
|
||||
SNIPPET = "snippet"
|
||||
POST = "post"
|
||||
|
||||
|
||||
class SlackBlockType(StrEnum):
|
||||
ACTIONS = "actions"
|
||||
CONTEXT = "context"
|
||||
DIVIDER = "divider"
|
||||
HEADER = "header"
|
||||
IMAGE = "image"
|
||||
INPUT = "input"
|
||||
SECTION = "section"
|
||||
VIDEO = "video"
|
||||
|
||||
|
||||
class SlackTextObjectType(StrEnum):
|
||||
MARKDOWN = "mrkdwn"
|
||||
PLAIN_TEXT = "plain_text"
|
||||
|
||||
|
||||
class SlackStatusEmojiDisplayInfo(StrictStateModel):
|
||||
emoji_name: str
|
||||
display_url: str
|
||||
|
||||
|
||||
class SlackProfileField(StrictStateModel):
|
||||
value: str
|
||||
alt: str | None = None
|
||||
|
||||
|
||||
class SlackUserProfile(StrictStateModel):
|
||||
avatar_hash: str | None = None
|
||||
status_text: str | None = None
|
||||
status_emoji: SlackStatusEmoji | None = None
|
||||
status_emoji_display_info: list[SlackStatusEmojiDisplayInfo] | None = None
|
||||
status_expiration: int | None = None
|
||||
status_text_canonical: str | None = None
|
||||
real_name: str | None = None
|
||||
display_name: str | None = None
|
||||
real_name_normalized: str | None = None
|
||||
display_name_normalized: str | None = None
|
||||
email: str | None = None
|
||||
pronouns: str | None = None
|
||||
huddle_state: str | None = None
|
||||
huddle_state_expiration_ts: int | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
title: str | None = None
|
||||
phone: str | None = None
|
||||
skype: str | None = None
|
||||
start_date: str | None = None
|
||||
team: str | None = None
|
||||
fields: dict[str, SlackProfileField] | None = None
|
||||
image_original: str | None = None
|
||||
is_custom_image: bool | None = None
|
||||
image_24: str | None = None
|
||||
image_32: str | None = None
|
||||
image_48: str | None = None
|
||||
image_72: str | None = None
|
||||
image_192: str | None = None
|
||||
image_512: str | None = None
|
||||
image_1024: str | None = None
|
||||
|
||||
|
||||
class SlackEnterpriseUser(StrictStateModel):
|
||||
id: NonEmptyString
|
||||
enterprise_id: NonEmptyString
|
||||
enterprise_name: NonEmptyString
|
||||
is_admin: bool
|
||||
is_owner: bool
|
||||
is_primary_owner: bool | None = None
|
||||
teams: list[str]
|
||||
|
||||
|
||||
class SlackUser(StrictStateModel):
|
||||
id: SlackUserId
|
||||
team_id: SlackTeamId = "T_MOCK"
|
||||
name: NonEmptyString
|
||||
deleted: bool = False
|
||||
color: str | None = None
|
||||
real_name: str | None = None
|
||||
tz: str | None = None
|
||||
tz_label: str | None = None
|
||||
tz_offset: int | None = None
|
||||
profile: SlackUserProfile = Field(default_factory=SlackUserProfile)
|
||||
is_admin: bool | None = None
|
||||
is_owner: bool | None = None
|
||||
is_primary_owner: bool | None = None
|
||||
is_restricted: bool | None = None
|
||||
is_ultra_restricted: bool | None = None
|
||||
is_bot: bool | None = None
|
||||
is_app_user: bool | None = None
|
||||
is_email_confirmed: bool | None = None
|
||||
is_invited_user: bool | None = None
|
||||
is_stranger: bool | None = None
|
||||
updated: int | None = None
|
||||
has_2fa: bool | None = None
|
||||
two_factor_type: SlackTwoFactorType | None = None
|
||||
locale: str | None = None
|
||||
who_can_share_contact_card: str | None = None
|
||||
always_active: bool | None = None
|
||||
enterprise_user: SlackEnterpriseUser | None = None
|
||||
|
||||
@field_validator("id", "name")
|
||||
@classmethod
|
||||
def _required_strings(cls, value: str) -> str:
|
||||
return _non_empty(value, "user field")
|
||||
|
||||
|
||||
class SlackChannelTopic(StrictStateModel):
|
||||
value: str = ""
|
||||
creator: SlackUserId | Literal[""] = ""
|
||||
last_set: int = 0
|
||||
|
||||
|
||||
class SlackChannelPostingRestrictions(StrictStateModel):
|
||||
type: list[str] | None = None
|
||||
subteam: list[str] | None = None
|
||||
user: list[str] | None = None
|
||||
|
||||
|
||||
class SlackChannelTab(StrictStateModel):
|
||||
id: NonEmptyString
|
||||
label: NonEmptyString
|
||||
type: NonEmptyString
|
||||
data: Any | None = None
|
||||
is_disabled: bool | None = None
|
||||
|
||||
|
||||
class SlackChannelProperties(StrictStateModel):
|
||||
posting_restricted_to: SlackChannelPostingRestrictions | None = None
|
||||
threads_restricted_to: SlackChannelPostingRestrictions | None = None
|
||||
at_channel_restricted: bool | None = None
|
||||
at_here_restricted: bool | None = None
|
||||
huddles_restricted: bool | None = None
|
||||
sharing_disabled: bool | None = None
|
||||
tabs: list[SlackChannelTab] | None = None
|
||||
default_tab_id: str | None = None
|
||||
auto_open_tab_id: str | None = None
|
||||
membership_limit: int | None = None
|
||||
canvas: Any | None = None
|
||||
|
||||
|
||||
class SlackChannel(StrictStateModel):
|
||||
id: SlackChannelId
|
||||
name: NonEmptyString
|
||||
is_channel: bool = True
|
||||
is_group: bool = False
|
||||
is_im: bool = False
|
||||
is_mpim: bool = False
|
||||
is_private: bool = False
|
||||
created: int = 0
|
||||
is_archived: bool = False
|
||||
is_general: bool = False
|
||||
is_frozen: bool | None = None
|
||||
is_read_only: bool | None = None
|
||||
is_thread_only: bool | None = None
|
||||
unlinked: int = 0
|
||||
name_normalized: str | None = None
|
||||
is_shared: bool = False
|
||||
is_org_shared: bool = False
|
||||
is_ext_shared: bool = False
|
||||
is_pending_ext_shared: bool = False
|
||||
pending_shared: list[str] = Field(default_factory=list)
|
||||
pending_connected_team_ids: list[str] = Field(default_factory=list)
|
||||
context_team_id: SlackTeamId = "T_MOCK"
|
||||
updated: int = 0
|
||||
parent_conversation: SlackChannelId | None = None
|
||||
creator: SlackUserId | Literal[""] = ""
|
||||
shared_team_ids: list[SlackTeamId] = Field(default_factory=lambda: ["T_MOCK"])
|
||||
is_member: bool = True
|
||||
conversation_host_id: str | None = None
|
||||
topic: SlackChannelTopic | None = None
|
||||
purpose: SlackChannelTopic | None = None
|
||||
properties: SlackChannelProperties | None = None
|
||||
previous_names: list[str] | None = None
|
||||
num_members: int | None = None
|
||||
last_read: SlackTs | None = None
|
||||
unread_count: int | None = None
|
||||
unread_count_display: int | None = None
|
||||
latest: dict[str, Any] | None = None
|
||||
user: SlackUserId | None = None
|
||||
members: list[SlackUserId] | None = None
|
||||
|
||||
@field_validator("id", "name")
|
||||
@classmethod
|
||||
def _required_strings(cls, value: str) -> str:
|
||||
return _non_empty(value, "channel field")
|
||||
|
||||
@field_validator("members")
|
||||
@classmethod
|
||||
def _members_are_unique(cls, value: list[str] | None) -> list[str] | None:
|
||||
if value is not None and len(set(value)) != len(value):
|
||||
raise ValueError("channel members must be unique")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _normalize_name(self) -> Self:
|
||||
if self.name_normalized is None:
|
||||
self.name_normalized = self.name
|
||||
kinds = [self.is_channel, self.is_group, self.is_im, self.is_mpim]
|
||||
if sum(1 for kind in kinds if kind) != 1:
|
||||
raise ValueError("exactly one Slack conversation kind flag must be true")
|
||||
if self.is_im and not self.user:
|
||||
raise ValueError("DM channels require user")
|
||||
if self.is_channel and self.is_private:
|
||||
raise ValueError("public channel flag is inconsistent with is_private")
|
||||
if (self.is_group or self.is_im or self.is_mpim) and not self.is_private:
|
||||
raise ValueError("private conversation flags require is_private")
|
||||
return self
|
||||
|
||||
|
||||
class SlackReaction(StrictStateModel):
|
||||
name: NonEmptyString
|
||||
users: list[SlackUserId] = Field(default_factory=list)
|
||||
count: int
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _name_required(cls, value: str) -> str:
|
||||
return _non_empty(value, "reaction.name").strip(":")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _count_matches_users(self) -> Self:
|
||||
if self.count != len(self.users):
|
||||
raise ValueError("reaction.count must match number of reaction users")
|
||||
if len(set(self.users)) != len(self.users):
|
||||
raise ValueError("reaction.users must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class SlackAttachmentField(StrictStateModel):
|
||||
title: NonEmptyString
|
||||
value: str
|
||||
short: bool
|
||||
|
||||
|
||||
class SlackAttachmentActionConfirm(StrictStateModel):
|
||||
title: str | None = None
|
||||
text: NonEmptyString
|
||||
ok_text: str | None = None
|
||||
dismiss_text: str | None = None
|
||||
|
||||
|
||||
class SlackAttachmentActionOption(StrictStateModel):
|
||||
text: NonEmptyString
|
||||
value: NonEmptyString
|
||||
|
||||
|
||||
class SlackAttachmentActionOptionGroup(StrictStateModel):
|
||||
text: NonEmptyString
|
||||
options: list[SlackAttachmentActionOption]
|
||||
|
||||
|
||||
class SlackAttachmentAction(StrictStateModel):
|
||||
id: str | None = None
|
||||
name: NonEmptyString
|
||||
text: NonEmptyString
|
||||
type: NonEmptyString
|
||||
value: str | None = None
|
||||
style: str | None = None
|
||||
url: str | None = None
|
||||
confirm: SlackAttachmentActionConfirm | None = None
|
||||
options: list[SlackAttachmentActionOption] | None = None
|
||||
option_groups: list[SlackAttachmentActionOptionGroup] | None = None
|
||||
data_source: str | None = None
|
||||
selected_options: list[SlackAttachmentActionOption] | None = None
|
||||
min_query_length: int | None = None
|
||||
|
||||
|
||||
class SlackAttachment(StrictStateModel):
|
||||
fallback: str | None = None
|
||||
color: str | None = None
|
||||
pretext: str | None = None
|
||||
author_name: str | None = None
|
||||
author_link: str | None = None
|
||||
author_icon: str | None = None
|
||||
author_subname: str | None = None
|
||||
title: str | None = None
|
||||
title_link: str | None = None
|
||||
text: str | None = None
|
||||
fields: list[SlackAttachmentField] | None = None
|
||||
image_url: str | None = None
|
||||
thumb_url: str | None = None
|
||||
thumb_width: int | None = None
|
||||
thumb_height: int | None = None
|
||||
footer: str | None = None
|
||||
footer_icon: str | None = None
|
||||
ts: str | None = None
|
||||
mrkdwn_in: list[str] | None = None
|
||||
actions: list[SlackAttachmentAction] | None = None
|
||||
callback_id: str | None = None
|
||||
id: int | None = None
|
||||
|
||||
|
||||
class SlackTextObject(StrictStateModel):
|
||||
type: SlackTextObjectType
|
||||
text: str
|
||||
emoji: bool | None = None
|
||||
verbatim: bool | None = None
|
||||
|
||||
|
||||
class SlackBlockElement(StrictStateModel):
|
||||
type: NonEmptyString
|
||||
text: str | SlackTextObject | None = None
|
||||
elements: list[SlackBlockElement] | None = None
|
||||
|
||||
|
||||
class SlackBlock(StrictStateModel):
|
||||
type: SlackBlockType
|
||||
block_id: str | None = None
|
||||
elements: list[SlackBlockElement] | None = None
|
||||
text: SlackTextObject | None = None
|
||||
accessory: SlackBlockElement | None = None
|
||||
fields: list[SlackTextObject] | None = None
|
||||
image_url: str | None = None
|
||||
alt_text: str | None = None
|
||||
title: SlackTextObject | None = None
|
||||
|
||||
|
||||
class SlackFileShares(StrictStateModel):
|
||||
public: dict[str, list[Any]] | None = None
|
||||
private: dict[str, list[Any]] | None = None
|
||||
|
||||
|
||||
class SlackFile(StrictStateModel):
|
||||
id: SlackFileId
|
||||
created: int | None = None
|
||||
timestamp: int | None = None
|
||||
name: str | None = None
|
||||
title: str | None = None
|
||||
mimetype: str | None = None
|
||||
filetype: str | None = None
|
||||
pretty_type: str | None = None
|
||||
user: SlackUserId | None = None
|
||||
user_team: SlackTeamId | None = None
|
||||
editable: bool | None = None
|
||||
size: int | None = None
|
||||
mode: SlackFileMode | None = None
|
||||
is_external: bool | None = None
|
||||
external_type: str | None = None
|
||||
is_public: bool | None = None
|
||||
public_url_shared: bool | None = None
|
||||
display_as_bot: bool | None = None
|
||||
username: str | None = None
|
||||
url_private: str | None = None
|
||||
url_private_download: str | None = None
|
||||
thumb_64: str | None = None
|
||||
thumb_80: str | None = None
|
||||
thumb_360: str | None = None
|
||||
thumb_360_w: int | None = None
|
||||
thumb_360_h: int | None = None
|
||||
thumb_480: str | None = None
|
||||
thumb_480_w: int | None = None
|
||||
thumb_480_h: int | None = None
|
||||
thumb_160: str | None = None
|
||||
thumb_720: str | None = None
|
||||
thumb_720_w: int | None = None
|
||||
thumb_720_h: int | None = None
|
||||
thumb_800: str | None = None
|
||||
thumb_800_w: int | None = None
|
||||
thumb_800_h: int | None = None
|
||||
thumb_960: str | None = None
|
||||
thumb_960_w: int | None = None
|
||||
thumb_960_h: int | None = None
|
||||
thumb_1024: str | None = None
|
||||
thumb_1024_w: int | None = None
|
||||
thumb_1024_h: int | None = None
|
||||
original_w: int | None = None
|
||||
original_h: int | None = None
|
||||
thumb_tiny: str | None = None
|
||||
permalink: str | None = None
|
||||
permalink_public: str | None = None
|
||||
edit_link: str | None = None
|
||||
preview: str | None = None
|
||||
preview_highlight: str | None = None
|
||||
lines: int | None = None
|
||||
lines_more: int | None = None
|
||||
preview_is_truncated: bool | None = None
|
||||
comments_count: int | None = None
|
||||
is_starred: bool | None = None
|
||||
shares: SlackFileShares | None = None
|
||||
channels: list[SlackChannelId] | None = None
|
||||
groups: list[SlackChannelId] | None = None
|
||||
ims: list[SlackChannelId] | None = None
|
||||
has_rich_preview: bool | None = None
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def _id_required(cls, value: str) -> str:
|
||||
return _non_empty(value, "file.id")
|
||||
|
||||
|
||||
class SlackMessageEdited(StrictStateModel):
|
||||
user: SlackUserId
|
||||
ts: SlackTs
|
||||
|
||||
|
||||
class SlackHuddleRoom(StrictStateModel):
|
||||
id: NonEmptyString
|
||||
name: str | None = None
|
||||
media_server: str | None = None
|
||||
created_by: SlackUserId
|
||||
date_start: int
|
||||
date_end: int
|
||||
participants: list[SlackUserId]
|
||||
participant_history: list[SlackUserId]
|
||||
participants_camera_on: list[SlackUserId]
|
||||
participants_camera_off: list[SlackUserId]
|
||||
participants_screenshare_on: list[SlackUserId]
|
||||
participants_screenshare_off: list[SlackUserId]
|
||||
canvas_thread_ts: SlackTs | None = None
|
||||
thread_root_ts: SlackTs | None = None
|
||||
channels: list[SlackChannelId]
|
||||
is_dm_call: bool
|
||||
was_rejected: bool
|
||||
was_missed: bool
|
||||
was_accepted: bool
|
||||
has_ended: bool
|
||||
background_id: str | None = None
|
||||
canvas_background: str | None = None
|
||||
is_prewarmed: bool
|
||||
is_scheduled: bool
|
||||
attached_file_ids: list[SlackFileId]
|
||||
media_backend_type: str | None = None
|
||||
display_id: str | None = None
|
||||
external_unique_id: str | None = None
|
||||
app_id: str | None = None
|
||||
call_family: str | None = None
|
||||
pending_invitees: dict[str, Any] | None = None
|
||||
last_invite_status_by_user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SlackBotProfile(StrictStateModel):
|
||||
id: NonEmptyString
|
||||
deleted: bool | None = None
|
||||
name: str
|
||||
updated: int | None = None
|
||||
app_id: str
|
||||
icons: dict[str, str] | None = None
|
||||
team_id: SlackTeamId
|
||||
|
||||
|
||||
class SlackMessageMetadata(StrictStateModel):
|
||||
event_type: str
|
||||
event_payload: dict[str, Any]
|
||||
|
||||
|
||||
class SlackMessage(StrictStateModel):
|
||||
type: SlackMessageType = SlackMessageType.MESSAGE
|
||||
subtype: SlackMessageSubtype | None = None
|
||||
user: SlackUserId | None = None
|
||||
bot_id: str | None = None
|
||||
bot_profile: SlackBotProfile | None = None
|
||||
text: str
|
||||
ts: SlackTs
|
||||
thread_ts: SlackTs | None = None
|
||||
parent_user_id: SlackUserId | None = None
|
||||
reply_count: int | None = None
|
||||
reply_users_count: int | None = None
|
||||
latest_reply: SlackTs | None = None
|
||||
reply_users: list[SlackUserId] | None = None
|
||||
is_locked: bool | None = None
|
||||
subscribed: bool | None = None
|
||||
team: SlackTeamId | None = None
|
||||
channel: SlackChannelId | None = None
|
||||
is_starred: bool | None = None
|
||||
pinned_to: list[SlackChannelId] | None = None
|
||||
pinned_info: dict[str, dict[str, Any]] | None = None
|
||||
reactions: list[SlackReaction] | None = None
|
||||
attachments: list[SlackAttachment] | None = None
|
||||
blocks: list[SlackBlock] | None = None
|
||||
files: list[SlackFile] | None = None
|
||||
upload: bool | None = None
|
||||
display_as_bot: bool | None = None
|
||||
edited: SlackMessageEdited | None = None
|
||||
client_msg_id: str | None = None
|
||||
permalink: str | None = None
|
||||
no_notifications: bool | None = None
|
||||
room: SlackHuddleRoom | None = None
|
||||
metadata: SlackMessageMetadata | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _reply_counts_are_consistent(self) -> Self:
|
||||
if (
|
||||
self.reply_users is not None
|
||||
and self.reply_users_count is not None
|
||||
and self.reply_users_count != len(self.reply_users)
|
||||
):
|
||||
raise ValueError("reply_users_count must match reply_users length")
|
||||
return self
|
||||
|
||||
|
||||
class SlackCounters(StrictStateModel):
|
||||
channelId: int = 1_000
|
||||
fileId: int = 1_000
|
||||
|
||||
|
||||
class SlackState(StrictStateModel):
|
||||
users: dict[SlackUserId, SlackUser] = Field(default_factory=dict)
|
||||
channels: dict[SlackChannelId, SlackChannel] = Field(default_factory=dict)
|
||||
messages: dict[SlackChannelId, list[SlackMessage]] = Field(default_factory=dict)
|
||||
bot_user_id: SlackUserId | None = None
|
||||
is_admin: bool = False
|
||||
counters: SlackCounters = Field(default_factory=SlackCounters)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _keys_and_references_are_consistent(self) -> Self:
|
||||
if self.bot_user_id is not None and self.bot_user_id not in self.users:
|
||||
raise ValueError(f"bot_user_id {self.bot_user_id!r} does not reference an existing user")
|
||||
|
||||
for key, user in self.users.items():
|
||||
if key != user.id:
|
||||
raise ValueError(f"users key {key!r} does not match user.id {user.id!r}")
|
||||
user_names: dict[str, str] = {}
|
||||
for user in self.users.values():
|
||||
normalized_name = user.name.casefold()
|
||||
if normalized_name in user_names:
|
||||
raise ValueError(
|
||||
f"user name {user.name!r} is shared by {user_names[normalized_name]!r} and {user.id!r}"
|
||||
)
|
||||
user_names[normalized_name] = user.id
|
||||
|
||||
for key, channel in self.channels.items():
|
||||
if key != channel.id:
|
||||
raise ValueError(f"channels key {key!r} does not match channel.id {channel.id!r}")
|
||||
if channel.creator and channel.creator not in self.users:
|
||||
raise ValueError(f"channel {key!r} creator {channel.creator!r} does not reference an existing user")
|
||||
for field_name in ("topic", "purpose"):
|
||||
topic = getattr(channel, field_name)
|
||||
if topic is not None and topic.creator and topic.creator not in self.users:
|
||||
raise ValueError(
|
||||
f"channel {key!r} {field_name}.creator {topic.creator!r} does not reference an existing user"
|
||||
)
|
||||
if channel.user is not None and channel.user not in self.users:
|
||||
raise ValueError(f"channel {key!r} user {channel.user!r} does not reference an existing user")
|
||||
for member in channel.members or []:
|
||||
if member not in self.users:
|
||||
raise ValueError(f"channel {key!r} member {member!r} does not reference an existing user")
|
||||
|
||||
for channel_id, messages in self.messages.items():
|
||||
if channel_id not in self.channels:
|
||||
raise ValueError(f"messages key {channel_id!r} does not reference an existing channel")
|
||||
seen: set[str] = set()
|
||||
message_by_ts = {message.ts: message for message in messages}
|
||||
for message in messages:
|
||||
if message.ts in seen:
|
||||
raise ValueError(f"duplicate message timestamp {message.ts!r} in channel {channel_id!r}")
|
||||
seen.add(message.ts)
|
||||
if message.thread_ts and message.thread_ts != message.ts and message.thread_ts not in message_by_ts:
|
||||
raise ValueError(f"thread reply {message.ts!r} references missing parent {message.thread_ts!r}")
|
||||
if message.channel is not None and message.channel != channel_id:
|
||||
raise ValueError(f"message.channel {message.channel!r} does not match messages key {channel_id!r}")
|
||||
self._validate_message_user_references(channel_id, message)
|
||||
self._validate_file_channel_references(channel_id, message)
|
||||
if message.pinned_to:
|
||||
for pinned_channel in message.pinned_to:
|
||||
if pinned_channel not in self.channels:
|
||||
raise ValueError(f"message {message.ts!r} pinned to unknown channel {pinned_channel!r}")
|
||||
self._validate_pin_info(message)
|
||||
self._validate_thread_metadata(channel_id, messages, message_by_ts)
|
||||
return self
|
||||
|
||||
def _validate_message_user_references(self, channel_id: str, message: SlackMessage) -> None:
|
||||
if message.user is not None and message.user not in self.users:
|
||||
raise ValueError(
|
||||
f"message {message.ts!r} in channel {channel_id!r} user {message.user!r} does not reference an existing user"
|
||||
)
|
||||
if message.parent_user_id is not None and message.parent_user_id not in self.users:
|
||||
raise ValueError(
|
||||
f"message {message.ts!r} in channel {channel_id!r} parent_user_id {message.parent_user_id!r} does not reference an existing user"
|
||||
)
|
||||
if message.edited is not None and message.edited.user not in self.users:
|
||||
raise ValueError(
|
||||
f"message {message.ts!r} edited.user {message.edited.user!r} does not reference an existing user"
|
||||
)
|
||||
for reply_user in message.reply_users or []:
|
||||
if reply_user not in self.users:
|
||||
raise ValueError(
|
||||
f"message {message.ts!r} reply user {reply_user!r} does not reference an existing user"
|
||||
)
|
||||
for reaction in message.reactions or []:
|
||||
for reaction_user in reaction.users:
|
||||
if reaction_user not in self.users:
|
||||
raise ValueError(
|
||||
f"message {message.ts!r} reaction user {reaction_user!r} does not reference an existing user"
|
||||
)
|
||||
for file in message.files or []:
|
||||
if file.user is not None and file.user not in self.users:
|
||||
raise ValueError(f"message {message.ts!r} file user {file.user!r} does not reference an existing user")
|
||||
if message.room is not None:
|
||||
room_users = [
|
||||
message.room.created_by,
|
||||
*message.room.participants,
|
||||
*message.room.participant_history,
|
||||
*message.room.participants_camera_on,
|
||||
*message.room.participants_camera_off,
|
||||
*message.room.participants_screenshare_on,
|
||||
*message.room.participants_screenshare_off,
|
||||
]
|
||||
for room_user in room_users:
|
||||
if room_user not in self.users:
|
||||
raise ValueError(
|
||||
f"message {message.ts!r} huddle user {room_user!r} does not reference an existing user"
|
||||
)
|
||||
|
||||
def _validate_file_channel_references(self, channel_id: str, message: SlackMessage) -> None:
|
||||
for file in message.files or []:
|
||||
for file_channel in file.channels or []:
|
||||
if file_channel not in self.channels:
|
||||
raise ValueError(
|
||||
f"message {message.ts!r} file channel {file_channel!r} does not reference an existing channel"
|
||||
)
|
||||
for file_group in file.groups or []:
|
||||
if file_group not in self.channels:
|
||||
raise ValueError(
|
||||
f"message {message.ts!r} file group {file_group!r} does not reference an existing channel"
|
||||
)
|
||||
for file_im in file.ims or []:
|
||||
if file_im not in self.channels:
|
||||
raise ValueError(
|
||||
f"message {message.ts!r} file im {file_im!r} does not reference an existing channel"
|
||||
)
|
||||
if message.room is not None:
|
||||
for room_channel in message.room.channels:
|
||||
if room_channel not in self.channels:
|
||||
raise ValueError(
|
||||
f"message {message.ts!r} huddle channel {room_channel!r} does not reference an existing channel"
|
||||
)
|
||||
if message.channel is not None and message.channel != channel_id:
|
||||
raise ValueError(f"message.channel {message.channel!r} does not match messages key {channel_id!r}")
|
||||
|
||||
def _validate_pin_info(self, message: SlackMessage) -> None:
|
||||
pinned_to = set(message.pinned_to or [])
|
||||
pinned_info = message.pinned_info or {}
|
||||
if pinned_info and not pinned_to:
|
||||
raise ValueError(f"message {message.ts!r} has pinned_info without pinned_to")
|
||||
extra_info_channels = set(pinned_info) - pinned_to
|
||||
if extra_info_channels:
|
||||
raise ValueError(f"message {message.ts!r} pinned_info contains channels not in pinned_to")
|
||||
for pinned_channel in pinned_to:
|
||||
info = pinned_info.get(pinned_channel)
|
||||
if info is None:
|
||||
continue
|
||||
pinned_by = info.get("pinned_by")
|
||||
if pinned_by is not None and pinned_by not in self.users:
|
||||
raise ValueError(f"message {message.ts!r} pinned_by {pinned_by!r} does not reference an existing user")
|
||||
pinned_ts = info.get("pinned_ts")
|
||||
if pinned_ts is not None and not isinstance(pinned_ts, int):
|
||||
raise ValueError(f"message {message.ts!r} pinned_ts for channel {pinned_channel!r} must be an integer")
|
||||
|
||||
def _validate_thread_metadata(
|
||||
self,
|
||||
channel_id: str,
|
||||
messages: list[SlackMessage],
|
||||
message_by_ts: dict[str, SlackMessage],
|
||||
) -> None:
|
||||
replies_by_parent: dict[str, list[SlackMessage]] = {}
|
||||
for message in messages:
|
||||
if message.thread_ts and message.thread_ts != message.ts:
|
||||
replies_by_parent.setdefault(message.thread_ts, []).append(message)
|
||||
|
||||
for parent_ts, replies in replies_by_parent.items():
|
||||
parent = message_by_ts[parent_ts]
|
||||
replies.sort(key=lambda reply: float(reply.ts))
|
||||
reply_users = sorted({reply.user for reply in replies if reply.user is not None})
|
||||
if parent.reply_count is not None and parent.reply_count != len(replies):
|
||||
raise ValueError(
|
||||
f"message {parent.ts!r} reply_count does not match actual replies in channel {channel_id!r}"
|
||||
)
|
||||
if parent.reply_users_count is not None and parent.reply_users_count != len(reply_users):
|
||||
raise ValueError(
|
||||
f"message {parent.ts!r} reply_users_count does not match actual reply users in channel {channel_id!r}"
|
||||
)
|
||||
if parent.reply_users is not None and set(parent.reply_users) != set(reply_users):
|
||||
raise ValueError(
|
||||
f"message {parent.ts!r} reply_users does not match actual reply users in channel {channel_id!r}"
|
||||
)
|
||||
if parent.latest_reply is not None and parent.latest_reply != replies[-1].ts:
|
||||
raise ValueError(
|
||||
f"message {parent.ts!r} latest_reply does not match latest actual reply in channel {channel_id!r}"
|
||||
)
|
||||
|
||||
for message in messages:
|
||||
if (
|
||||
message.reply_count or message.reply_users_count or message.reply_users or message.latest_reply
|
||||
) and message.ts not in replies_by_parent:
|
||||
raise ValueError(
|
||||
f"message {message.ts!r} has reply metadata but no actual replies in channel {channel_id!r}"
|
||||
)
|
||||
|
||||
|
||||
class SlackWorkspacesState(StrictStateModel):
|
||||
workspaces: dict[SlackWorkspaceId, SlackState] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _has_workspaces(self) -> Self:
|
||||
if not self.workspaces:
|
||||
raise ValueError("workspaces must contain at least one workspace")
|
||||
return self
|
||||
|
||||
|
||||
SlackMockState = SlackState | SlackWorkspacesState
|
||||
@@ -0,0 +1,541 @@
|
||||
"""Python Slack mock MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import Field
|
||||
|
||||
from slack_mock.models import SlackMockState, SlackStatusEmoji
|
||||
from slack_mock.state import set_active_workspace, write_snapshots
|
||||
from slack_mock.tools import channels, dms, files, messages, reactions_pins, users
|
||||
from slack_mock.tools import state as state_tools
|
||||
from slack_mock.tools.common import (
|
||||
ChannelIdArg,
|
||||
CursorArg,
|
||||
FileContentBase64Arg,
|
||||
FilenameArg,
|
||||
LimitArg,
|
||||
MessageTextArg,
|
||||
ReactionArg,
|
||||
SearchQueryArg,
|
||||
TimestampArg,
|
||||
UserIdArg,
|
||||
UserIdsArg,
|
||||
WorkspaceIdArg,
|
||||
)
|
||||
|
||||
mcp = FastMCP("slack-mock-service")
|
||||
|
||||
|
||||
def _snapshot_on_write(fn):
|
||||
@functools.wraps(fn)
|
||||
async def wrapper(*args, **kwargs):
|
||||
result = fn(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
write_snapshots()
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _with_workspace(fn):
|
||||
@functools.wraps(fn)
|
||||
async def wrapper(*args, **kwargs):
|
||||
workspace_id = kwargs.pop("workspace_id", None)
|
||||
if workspace_id is not None:
|
||||
set_active_workspace(workspace_id)
|
||||
result = fn(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
def list_channels(
|
||||
limit: LimitArg = 100,
|
||||
cursor: CursorArg | None = None,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List active public/private channels visible to the bot.
|
||||
|
||||
This mirrors Slack's default conversations.list shape for normal named
|
||||
channels: archived channels, DMs, and MPIMs are excluded. Results are
|
||||
paginated with a numeric cursor returned in response_metadata.next_cursor.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of channels to return, capped at 200.
|
||||
cursor: Pagination cursor from a previous response.
|
||||
|
||||
Returns:
|
||||
Slack-style response with channels and response_metadata.next_cursor.
|
||||
"""
|
||||
return channels.list_channels(limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def post_message(
|
||||
channel_id: ChannelIdArg,
|
||||
text: MessageTextArg,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Post a new top-level message to a channel, DM, or MPIM."""
|
||||
return messages.post_message(channel_id=channel_id, text=text)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def reply_to_thread(
|
||||
channel_id: ChannelIdArg,
|
||||
thread_ts: TimestampArg,
|
||||
text: MessageTextArg,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reply to an existing message thread.
|
||||
|
||||
Args:
|
||||
channel_id: Channel, DM, or MPIM containing the parent message.
|
||||
thread_ts: Timestamp of the parent message to reply to.
|
||||
text: Reply text.
|
||||
|
||||
Returns:
|
||||
Slack-style response with the new reply timestamp and message.
|
||||
"""
|
||||
return messages.reply_to_thread(channel_id=channel_id, thread_ts=thread_ts, text=text)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def add_reaction(
|
||||
channel_id: ChannelIdArg,
|
||||
timestamp: TimestampArg,
|
||||
reaction: ReactionArg,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a reaction emoji to a message.
|
||||
|
||||
The reaction may be provided with or without surrounding colons, e.g.
|
||||
`thumbsup` and `:thumbsup:` are equivalent.
|
||||
"""
|
||||
return reactions_pins.add_reaction(channel_id=channel_id, timestamp=timestamp, reaction=reaction)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
def get_channel_history(
|
||||
channel_id: ChannelIdArg,
|
||||
limit: LimitArg = 10,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Get recent top-level messages from a channel, DM, or MPIM.
|
||||
|
||||
Thread replies are not returned here unless the reply is itself the
|
||||
thread parent. Use get_thread_replies with the parent timestamp to inspect
|
||||
a full thread.
|
||||
|
||||
Args:
|
||||
channel_id: Channel, DM, or MPIM to read.
|
||||
limit: Maximum number of top-level messages to return.
|
||||
|
||||
Returns:
|
||||
Slack-style response with messages ordered newest first.
|
||||
"""
|
||||
return messages.get_channel_history(channel_id=channel_id, limit=limit)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
def get_thread_replies(
|
||||
channel_id: ChannelIdArg,
|
||||
thread_ts: TimestampArg,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Get a thread parent and all replies.
|
||||
|
||||
Args:
|
||||
channel_id: Channel, DM, or MPIM containing the thread.
|
||||
thread_ts: Timestamp of the thread parent message.
|
||||
|
||||
Returns:
|
||||
Slack-style response with the parent message followed by replies.
|
||||
"""
|
||||
return messages.get_thread_replies(channel_id=channel_id, thread_ts=thread_ts)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
def get_users(
|
||||
cursor: CursorArg | None = None,
|
||||
limit: LimitArg = 100,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Get workspace users with basic profile information.
|
||||
|
||||
Args:
|
||||
cursor: Pagination cursor from a previous response.
|
||||
limit: Maximum number of users to return, capped at 200.
|
||||
|
||||
Returns:
|
||||
Slack-style response with members and response_metadata.next_cursor.
|
||||
"""
|
||||
return users.get_users(cursor=cursor, limit=limit)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
def get_user_profile(user_id: UserIdArg, workspace_id: WorkspaceIdArg | None = None) -> dict[str, Any]:
|
||||
"""Get detailed profile information for a specific user."""
|
||||
return users.get_user_profile(user_id=user_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
def search_messages(
|
||||
query: SearchQueryArg,
|
||||
channel_id: ChannelIdArg | None = None,
|
||||
limit: Annotated[
|
||||
int | None,
|
||||
Field(ge=0, description="Maximum number of matches to return. Values above 100 are capped."),
|
||||
] = None,
|
||||
cursor: CursorArg | None = None,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Search messages across channels, DMs, and MPIMs.
|
||||
|
||||
Bare words are ANDed together and matched against message text plus sender
|
||||
names/profile fields. Double-quoted text requires exact adjacency.
|
||||
|
||||
Supported filters:
|
||||
in:general match channel name or channel ID
|
||||
from:alice match sender by username, display name, real name, email, or user ID
|
||||
from:@alice match sender username exactly
|
||||
after:2026-05-01 messages at or after a date or Unix timestamp
|
||||
before:2026-05-02 messages before a date or Unix timestamp
|
||||
during:2026-05 messages during a year, month, day, or parseable date
|
||||
has:link messages containing a URL or linked attachment/file
|
||||
has:reaction messages with reactions
|
||||
has:pin pinned messages
|
||||
|
||||
Unsupported or malformed operators are reported in response_metadata.warnings
|
||||
while preserving best-effort search behavior. `from:me` is unsupported
|
||||
because caller identity is not modeled and will match no users.
|
||||
|
||||
Args:
|
||||
query: Search text with optional Slack-style filters.
|
||||
channel_id: Optional channel/DM/MPIM scope.
|
||||
limit: Maximum matches to return, capped at 100.
|
||||
cursor: Pagination cursor from a previous response.
|
||||
|
||||
Returns:
|
||||
Slack-style search response with matches, total, next_cursor, and warnings.
|
||||
"""
|
||||
return messages.search_messages(query=query, channel_id=channel_id, limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def create_channel(
|
||||
name: Annotated[str, Field(min_length=1, description="Channel name.")],
|
||||
is_private: bool = False,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new public or private channel.
|
||||
|
||||
Channel names are normalized to lowercase. Duplicates are rejected against
|
||||
existing named public/private channels, but DMs and MPIMs are ignored for
|
||||
name uniqueness.
|
||||
"""
|
||||
return channels.create_channel(name=name, is_private=is_private)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def archive_channel(channel_id: ChannelIdArg, workspace_id: WorkspaceIdArg | None = None) -> dict[str, Any]:
|
||||
"""Archive a named public/private channel.
|
||||
|
||||
DMs and MPIMs cannot be archived and return
|
||||
method_not_supported_for_channel_type.
|
||||
"""
|
||||
return channels.archive_channel(channel_id=channel_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def rename_channel(
|
||||
channel_id: ChannelIdArg,
|
||||
name: Annotated[str, Field(min_length=1, description="New channel name.")],
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Rename a named public/private channel.
|
||||
|
||||
Names are normalized to lowercase. DMs and MPIMs cannot be renamed.
|
||||
Duplicate names among named channels are rejected.
|
||||
"""
|
||||
return channels.rename_channel(channel_id=channel_id, name=name)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def set_channel_topic(
|
||||
channel_id: ChannelIdArg,
|
||||
topic: Annotated[str | None, Field(description="New channel topic.")] = None,
|
||||
purpose: Annotated[str | None, Field(description="New channel purpose.")] = None,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Set a channel's topic and/or purpose.
|
||||
|
||||
Pass either `topic`, `purpose`, or both. Omitted fields are left unchanged.
|
||||
|
||||
Args:
|
||||
channel_id: Channel to update.
|
||||
topic: New topic text, if changing the topic.
|
||||
purpose: New purpose text, if changing the purpose.
|
||||
|
||||
Returns:
|
||||
Slack-style response with the updated channel.
|
||||
"""
|
||||
return channels.set_channel_topic(channel_id=channel_id, topic=topic, purpose=purpose)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def edit_message(
|
||||
channel_id: ChannelIdArg,
|
||||
ts: TimestampArg,
|
||||
text: MessageTextArg,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Edit an existing message.
|
||||
|
||||
In normal mode, only messages authored by the bot can be edited. In admin
|
||||
mode (`is_admin: true` in state), any message can be edited.
|
||||
"""
|
||||
return messages.edit_message(channel_id=channel_id, ts=ts, text=text)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def delete_message(
|
||||
channel_id: ChannelIdArg,
|
||||
ts: TimestampArg,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Delete a message from a channel, DM, or MPIM.
|
||||
|
||||
In normal mode, only messages authored by the bot can be deleted. In admin
|
||||
mode (`is_admin: true` in state), any message can be deleted. Deleting a
|
||||
thread parent also removes its replies.
|
||||
"""
|
||||
return messages.delete_message(channel_id=channel_id, ts=ts)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def pin_message(
|
||||
channel_id: ChannelIdArg,
|
||||
timestamp: TimestampArg,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Pin a message to a channel, DM, or MPIM."""
|
||||
return reactions_pins.pin_message(channel_id=channel_id, timestamp=timestamp)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def unpin_message(
|
||||
channel_id: ChannelIdArg,
|
||||
timestamp: TimestampArg,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Unpin a previously pinned message from a channel, DM, or MPIM."""
|
||||
return reactions_pins.unpin_message(channel_id=channel_id, timestamp=timestamp)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
def list_pins(channel_id: ChannelIdArg, workspace_id: WorkspaceIdArg | None = None) -> dict[str, Any]:
|
||||
"""List all pinned messages in a channel, DM, or MPIM."""
|
||||
return reactions_pins.list_pins(channel_id=channel_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def set_user_status(
|
||||
status_text: str,
|
||||
status_emoji: SlackStatusEmoji | None = None,
|
||||
user_id: UserIdArg | None = None,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Set a Slack user's status text and emoji.
|
||||
|
||||
By default this updates the bot user's profile. Passing `user_id` updates
|
||||
another user only when admin mode is enabled (`is_admin: true` in state).
|
||||
Status emoji must be empty or colon-wrapped, e.g. `:spiral_calendar_pad:`.
|
||||
|
||||
Args:
|
||||
status_text: Status text to show on the user profile.
|
||||
status_emoji: Optional Slack emoji code.
|
||||
user_id: Optional target user ID. Requires admin mode unless it is the bot.
|
||||
|
||||
Returns:
|
||||
Slack-style response with the updated profile.
|
||||
"""
|
||||
return users.set_user_status(status_text=status_text, status_emoji=status_emoji, user_id=user_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
def get_user_presence(user_id: UserIdArg, workspace_id: WorkspaceIdArg | None = None) -> dict[str, Any]:
|
||||
"""Get a user's current presence status."""
|
||||
return users.get_user_presence(user_id=user_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def upload_file(
|
||||
channel_id: ChannelIdArg,
|
||||
filename: FilenameArg,
|
||||
content_base64: FileContentBase64Arg,
|
||||
title: str | None = None,
|
||||
initial_comment: str | None = None,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Upload a file to a channel, DM, or MPIM.
|
||||
|
||||
The file body must be base64-encoded. The mock infers mimetype/filetype
|
||||
from the filename extension, creates a hosted Slack file object, and posts
|
||||
a file_share message in the target conversation.
|
||||
|
||||
Args:
|
||||
channel_id: Conversation to upload into.
|
||||
filename: Stored filename, including extension when known.
|
||||
content_base64: Base64-encoded file bytes.
|
||||
title: Optional display title. Defaults to filename.
|
||||
initial_comment: Optional message text for the file_share post.
|
||||
|
||||
Returns:
|
||||
Slack-style response with the created file object.
|
||||
"""
|
||||
return files.upload_file(
|
||||
channel_id=channel_id,
|
||||
filename=filename,
|
||||
content_base64=content_base64,
|
||||
title=title,
|
||||
initial_comment=initial_comment,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
def list_files(
|
||||
channel_id: ChannelIdArg,
|
||||
limit: LimitArg = 20,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List files shared in a channel, DM, or MPIM, newest first."""
|
||||
return files.list_files(channel_id=channel_id, limit=limit)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def open_dm(user_id: UserIdArg, workspace_id: WorkspaceIdArg | None = None) -> dict[str, Any]:
|
||||
"""Open or reuse a direct message conversation with one user.
|
||||
|
||||
If a DM with the user already exists, that channel is returned. Otherwise
|
||||
a new DM channel is created.
|
||||
"""
|
||||
return dms.open_dm(user_id=user_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def open_mpim(user_ids: UserIdsArg, workspace_id: WorkspaceIdArg | None = None) -> dict[str, Any]:
|
||||
"""Open or reuse a multi-party direct message conversation.
|
||||
|
||||
The bot user is included automatically. At least two non-bot users are
|
||||
required. Duplicate user IDs are ignored. If an MPIM with the same member
|
||||
set already exists, it is returned.
|
||||
|
||||
Args:
|
||||
user_ids: Users to include in the MPIM, excluding or including the bot.
|
||||
|
||||
Returns:
|
||||
Slack-style response with the MPIM channel.
|
||||
"""
|
||||
return dms.open_mpim(user_ids=user_ids)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
def list_dms(limit: LimitArg = 20, workspace_id: WorkspaceIdArg | None = None) -> dict[str, Any]:
|
||||
"""List direct message and multi-party direct message conversations."""
|
||||
return dms.list_dms(limit=limit)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def send_dm(user_id: UserIdArg, text: MessageTextArg, workspace_id: WorkspaceIdArg | None = None) -> dict[str, Any]:
|
||||
"""Send a direct message to a user.
|
||||
|
||||
Opens or reuses the DM first, then posts the message.
|
||||
"""
|
||||
return dms.send_dm(user_id=user_id, text=text)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_workspace
|
||||
@_snapshot_on_write
|
||||
def send_mpim(
|
||||
user_ids: UserIdsArg,
|
||||
text: MessageTextArg,
|
||||
workspace_id: WorkspaceIdArg | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a message to a multi-party direct message conversation.
|
||||
|
||||
Opens or reuses the MPIM for the provided member set, then posts the
|
||||
message. At least two non-bot users are required.
|
||||
"""
|
||||
return dms.send_mpim(user_ids=user_ids, text=text)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_workspaces() -> dict[str, Any]:
|
||||
"""List available Slack workspaces in the loaded mock state."""
|
||||
return state_tools.list_workspaces()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def export_state() -> SlackMockState:
|
||||
"""Export the full Slack state as JSON."""
|
||||
return state_tools.export_state()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_snapshot_on_write
|
||||
def import_state(state: dict[str, Any]) -> dict[str, bool]:
|
||||
"""Replace the full Slack state with the provided JSON."""
|
||||
return state_tools.import_state(state)
|
||||
@@ -0,0 +1,553 @@
|
||||
"""Slack state loading, saving, and mutation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from slack_mock.models import SlackFile, SlackMessage, SlackMockState, SlackState, SlackWorkspacesState
|
||||
|
||||
SERVICE_NAME = "slack"
|
||||
DEFAULT_BOT_USER_ID = "U_MOCK_BOT"
|
||||
_UNSET = object()
|
||||
|
||||
_workspaces: dict[str, SlackState] = {}
|
||||
_active_workspace_id = "default"
|
||||
_current_state: SlackState | None = None
|
||||
_last_timestamp_us = 0
|
||||
_final_path: Path | None = None
|
||||
_bundle_state_path: Path | None = None
|
||||
|
||||
|
||||
def resolve_bundle_state_paths() -> list[Path]:
|
||||
"""Resolve the seed-state files inside this service's bundle subdir.
|
||||
|
||||
The folder ``<BUNDLEDIR>/services/<name>/`` is the unit: everything in it
|
||||
is this service's seed. Prefer the canonical single-file ``state.json``
|
||||
(the output round-trip shape); otherwise hand back ALL ``*.json`` in the
|
||||
folder (the raw entities layout, e.g. per-entity files), which the
|
||||
caller coalesces the same way it merges INPUTDIR files.
|
||||
"""
|
||||
bundle_dir = os.environ.get("BUNDLEDIR")
|
||||
if not bundle_dir:
|
||||
return []
|
||||
service_dir = Path(bundle_dir) / "services" / SERVICE_NAME
|
||||
state_file = service_dir / "state.json"
|
||||
if state_file.is_file():
|
||||
return [state_file]
|
||||
if service_dir.is_dir():
|
||||
return sorted(service_dir.glob("*.json"))
|
||||
return []
|
||||
|
||||
|
||||
def resolve_bundle_state_path() -> Path | None:
|
||||
"""Back-compat single-file view of :func:`resolve_bundle_state_paths`."""
|
||||
paths = resolve_bundle_state_paths()
|
||||
return paths[0] if paths else None
|
||||
|
||||
|
||||
def resolve_bundle_output_path() -> Path | None:
|
||||
output_dir = os.environ.get("BUNDLE_OUTPUT_DIR")
|
||||
if not output_dir:
|
||||
return None
|
||||
return Path(output_dir) / "state.json"
|
||||
|
||||
|
||||
def resolve_input_paths() -> list[Path]:
|
||||
input_dir = os.environ.get("INPUTDIR")
|
||||
if not input_dir:
|
||||
return []
|
||||
path = Path(input_dir)
|
||||
if not path.is_dir():
|
||||
return []
|
||||
return sorted(path.glob("*.json"))
|
||||
|
||||
|
||||
def _default_bot_user() -> dict[str, Any]:
|
||||
return {
|
||||
"id": DEFAULT_BOT_USER_ID,
|
||||
"name": "slackbot",
|
||||
"real_name": "Mock Bot",
|
||||
"profile": {"display_name": "slackbot", "status_text": "", "status_emoji": ""},
|
||||
"is_bot": True,
|
||||
"deleted": False,
|
||||
}
|
||||
|
||||
|
||||
def get_default_state() -> SlackState:
|
||||
return SlackState.model_validate(
|
||||
{
|
||||
"bot_user_id": DEFAULT_BOT_USER_ID,
|
||||
"users": {DEFAULT_BOT_USER_ID: _default_bot_user()},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_default_workspaces() -> dict[str, SlackState]:
|
||||
return {"default": get_default_state()}
|
||||
|
||||
|
||||
def state_to_json() -> dict[str, Any]:
|
||||
_ensure_loaded()
|
||||
return _storage_from_workspaces(_workspaces)
|
||||
|
||||
|
||||
def state_from_json(data: dict[str, Any] | SlackMockState) -> None:
|
||||
workspaces = _workspaces_from_storage(data)
|
||||
_install_workspaces(workspaces)
|
||||
|
||||
|
||||
def _canonicalize_state(data: dict[str, Any] | SlackState) -> SlackState:
|
||||
next_state = data if isinstance(data, SlackState) else SlackState.model_validate(data)
|
||||
next_state.counters.channelId = max(next_state.counters.channelId, _next_channel_id_counter(next_state))
|
||||
next_state.counters.fileId = max(next_state.counters.fileId, _max_file_id(next_state))
|
||||
return SlackState.model_validate(next_state.model_dump(mode="json", by_alias=True))
|
||||
|
||||
|
||||
def _workspaces_from_storage(data: dict[str, Any] | SlackMockState) -> dict[str, SlackState]:
|
||||
global _last_timestamp_us
|
||||
|
||||
if isinstance(data, SlackWorkspacesState):
|
||||
raw_workspaces = data.workspaces
|
||||
elif isinstance(data, SlackState):
|
||||
raw_workspaces = {"default": data}
|
||||
elif "workspaces" in data:
|
||||
raw_workspaces = SlackWorkspacesState.model_validate(data).workspaces
|
||||
else:
|
||||
merged = get_default_state().model_dump(mode="json", by_alias=True)
|
||||
if "users" in data:
|
||||
merged["users"].update(data["users"])
|
||||
if "channels" in data:
|
||||
merged["channels"].update(data["channels"])
|
||||
if "messages" in data:
|
||||
merged["messages"].update(data["messages"])
|
||||
if "counters" in data:
|
||||
counters = data["counters"]
|
||||
if "channelId" in counters:
|
||||
merged["counters"]["channelId"] = max(merged["counters"]["channelId"], counters["channelId"])
|
||||
if "fileId" in counters:
|
||||
merged["counters"]["fileId"] = max(merged["counters"]["fileId"], counters["fileId"])
|
||||
if "bot_user_id" in data:
|
||||
merged["bot_user_id"] = data["bot_user_id"]
|
||||
if data.get("is_admin") is True:
|
||||
merged["is_admin"] = True
|
||||
raw_workspaces = {"default": merged}
|
||||
|
||||
next_timestamp_us = 0
|
||||
workspaces: dict[str, SlackState] = {}
|
||||
for workspace_id, workspace_state in raw_workspaces.items():
|
||||
canonical = _canonicalize_state(workspace_state)
|
||||
next_timestamp_us = max(next_timestamp_us, _max_message_timestamp_us(canonical))
|
||||
workspaces[workspace_id] = canonical
|
||||
if not workspaces:
|
||||
raise ValueError("Slack state must contain at least one workspace")
|
||||
_last_timestamp_us = next_timestamp_us
|
||||
return workspaces
|
||||
|
||||
|
||||
def _storage_from_workspaces(workspaces: dict[str, SlackState]) -> dict[str, Any]:
|
||||
if set(workspaces) == {"default"}:
|
||||
return workspaces["default"].model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
return {
|
||||
"workspaces": {
|
||||
workspace_id: workspace.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
for workspace_id, workspace in workspaces.items()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _install_workspaces(workspaces: dict[str, SlackState]) -> None:
|
||||
global _active_workspace_id, _current_state
|
||||
_workspaces.clear()
|
||||
_workspaces.update(workspaces)
|
||||
_active_workspace_id = "default" if "default" in _workspaces else next(iter(_workspaces))
|
||||
_current_state = _workspaces[_active_workspace_id]
|
||||
|
||||
|
||||
def _ensure_loaded() -> None:
|
||||
if _current_state is None:
|
||||
load_state()
|
||||
|
||||
|
||||
def _next_channel_id_counter(state: SlackState) -> int:
|
||||
next_channel_id = state.counters.channelId
|
||||
for channel_id in state.channels:
|
||||
match = re.match(r"^[CG]0*(\d+)$", channel_id)
|
||||
if match:
|
||||
next_channel_id = max(next_channel_id, int(match.group(1)) + 1)
|
||||
return next_channel_id
|
||||
|
||||
|
||||
def _max_file_id(state: SlackState) -> int:
|
||||
max_file_id = state.counters.fileId
|
||||
for messages in state.messages.values():
|
||||
for message in messages:
|
||||
for file in message.files or []:
|
||||
match = re.match(r"^F0*(\d+)$", file.id)
|
||||
if match:
|
||||
max_file_id = max(max_file_id, int(match.group(1)))
|
||||
return max_file_id
|
||||
|
||||
|
||||
def _max_message_timestamp_us(state: SlackState) -> int:
|
||||
max_timestamp_us = 0
|
||||
for messages in state.messages.values():
|
||||
for message in messages:
|
||||
max_timestamp_us = max(max_timestamp_us, _timestamp_to_microseconds(message.ts))
|
||||
return max_timestamp_us
|
||||
|
||||
|
||||
def _timestamp_to_microseconds(ts: str) -> int:
|
||||
seconds, micros = ts.split(".", 1)
|
||||
return int(seconds) * 1_000_000 + int(micros.ljust(6, "0")[:6])
|
||||
|
||||
|
||||
def dump_state(dest: Path) -> None:
|
||||
if _current_state is None and not _workspaces:
|
||||
return
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(json.dumps(state_to_json(), indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def set_snapshot_paths(
|
||||
*,
|
||||
final_path: Path | str | None | object = _UNSET,
|
||||
bundle_state_path: Path | str | None | object = _UNSET,
|
||||
) -> None:
|
||||
"""Configure optional snapshot destinations used after write tools."""
|
||||
global _final_path, _bundle_state_path
|
||||
if final_path is not _UNSET:
|
||||
_final_path = Path(cast(str | Path, final_path)) if final_path is not None else None
|
||||
if bundle_state_path is not _UNSET:
|
||||
_bundle_state_path = Path(cast(str | Path, bundle_state_path)) if bundle_state_path is not None else None
|
||||
|
||||
|
||||
def write_snapshots() -> None:
|
||||
if _bundle_state_path is not None:
|
||||
dump_state(_bundle_state_path)
|
||||
if _final_path is not None:
|
||||
dump_state(_final_path)
|
||||
|
||||
|
||||
def merge_inputdir_files(paths: list[Path]) -> dict[str, Any]:
|
||||
if len(paths) == 1:
|
||||
try:
|
||||
only = json.loads(paths[0].read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
print(f"Error loading Slack state from {paths[0]}: {exc}")
|
||||
else:
|
||||
if "workspaces" in only:
|
||||
print(f"Loaded Slack state from {paths[0]}")
|
||||
return only
|
||||
|
||||
merged = get_default_state().model_dump(mode="json", by_alias=True)
|
||||
workspace_parts: dict[str, Any] = {}
|
||||
legacy_seen = False
|
||||
for file in paths:
|
||||
try:
|
||||
partial = json.loads(file.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
print(f"Error loading Slack state from {file}: {exc}")
|
||||
continue
|
||||
|
||||
if "workspaces" in partial:
|
||||
workspace_parts.update(partial["workspaces"])
|
||||
print(f"Loaded Slack state from {file}")
|
||||
continue
|
||||
|
||||
legacy_seen = True
|
||||
if "users" in partial:
|
||||
merged["users"].update(partial["users"])
|
||||
if "channels" in partial:
|
||||
merged["channels"].update(partial["channels"])
|
||||
if "messages" in partial:
|
||||
merged["messages"].update(partial["messages"])
|
||||
if "counters" in partial:
|
||||
counters = partial["counters"]
|
||||
if "channelId" in counters:
|
||||
merged["counters"]["channelId"] = max(merged["counters"]["channelId"], counters["channelId"])
|
||||
if "fileId" in counters:
|
||||
merged["counters"]["fileId"] = max(merged["counters"]["fileId"], counters["fileId"])
|
||||
if "bot_user_id" in partial:
|
||||
merged["bot_user_id"] = partial["bot_user_id"]
|
||||
if partial.get("is_admin") is True:
|
||||
merged["is_admin"] = True
|
||||
print(f"Loaded Slack state from {file}")
|
||||
if workspace_parts:
|
||||
if legacy_seen:
|
||||
workspace_parts.setdefault("default", merged)
|
||||
return {"workspaces": workspace_parts}
|
||||
return merged
|
||||
|
||||
|
||||
def load_state() -> SlackState:
|
||||
if _current_state is not None:
|
||||
return _current_state
|
||||
|
||||
# The bundle folder is read in full and coalesced exactly like INPUTDIR:
|
||||
# both feed the same merge so a folder of per-entity *.json works without
|
||||
# special-casing. A lone state.json is just a one-element list.
|
||||
seed_paths = resolve_bundle_state_paths() or resolve_input_paths()
|
||||
seed = merge_inputdir_files(seed_paths) if seed_paths else get_default_state().model_dump(mode="json")
|
||||
|
||||
state_from_json(seed)
|
||||
|
||||
return get_state()
|
||||
|
||||
|
||||
def init_state() -> None:
|
||||
load_state()
|
||||
bundle_output = resolve_bundle_output_path()
|
||||
output_dir = Path(value) if (value := os.environ.get("OUTPUTDIR")) else None
|
||||
set_snapshot_paths(
|
||||
bundle_state_path=bundle_output,
|
||||
final_path=output_dir / "final.json" if output_dir is not None else None,
|
||||
)
|
||||
if bundle_output is not None:
|
||||
dump_state(bundle_output)
|
||||
if output_dir is not None:
|
||||
dump_state(output_dir / "initial.json")
|
||||
|
||||
|
||||
def save_state() -> None:
|
||||
if _current_state is None:
|
||||
return
|
||||
_workspaces[_active_workspace_id] = _current_state
|
||||
_canonicalize_workspaces()
|
||||
|
||||
|
||||
def _canonicalize_workspaces() -> None:
|
||||
"""Validate all loaded workspaces after in-memory mutations."""
|
||||
global _current_state
|
||||
if not _workspaces:
|
||||
return
|
||||
validated = SlackWorkspacesState.model_validate(
|
||||
{
|
||||
"workspaces": {
|
||||
workspace_id: workspace.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
for workspace_id, workspace in _workspaces.items()
|
||||
}
|
||||
}
|
||||
).workspaces
|
||||
_workspaces.clear()
|
||||
_workspaces.update(validated)
|
||||
_current_state = _workspaces[_active_workspace_id]
|
||||
|
||||
|
||||
def _restore_state(snapshot: SlackState) -> None:
|
||||
global _current_state
|
||||
_current_state = snapshot
|
||||
_workspaces[_active_workspace_id] = snapshot
|
||||
|
||||
|
||||
def mutate_state[T](mutator: Callable[[SlackState], T]) -> T:
|
||||
state = get_state()
|
||||
snapshot = state.model_copy(deep=True)
|
||||
try:
|
||||
result = mutator(state)
|
||||
save_state()
|
||||
except Exception:
|
||||
_restore_state(snapshot)
|
||||
raise
|
||||
return result
|
||||
|
||||
|
||||
def get_state() -> SlackState:
|
||||
if _current_state is None:
|
||||
return load_state()
|
||||
return _current_state
|
||||
|
||||
|
||||
def reset_state() -> None:
|
||||
global _last_timestamp_us
|
||||
_install_workspaces(get_default_workspaces())
|
||||
_last_timestamp_us = 0
|
||||
save_state()
|
||||
|
||||
|
||||
def get_active_workspace_id() -> str:
|
||||
_ensure_loaded()
|
||||
return _active_workspace_id
|
||||
|
||||
|
||||
def set_active_workspace(workspace_id: str) -> None:
|
||||
global _active_workspace_id, _current_state
|
||||
_ensure_loaded()
|
||||
if workspace_id not in _workspaces:
|
||||
raise ValueError(f"Unknown Slack workspace {workspace_id!r}")
|
||||
_active_workspace_id = workspace_id
|
||||
_current_state = _workspaces[workspace_id]
|
||||
|
||||
|
||||
def list_workspaces() -> dict[str, Any]:
|
||||
_ensure_loaded()
|
||||
workspaces = []
|
||||
for workspace_id, workspace in _workspaces.items():
|
||||
message_count = sum(len(messages) for messages in workspace.messages.values())
|
||||
workspaces.append(
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"is_active": workspace_id == _active_workspace_id,
|
||||
"user_count": len(workspace.users),
|
||||
"channel_count": len(workspace.channels),
|
||||
"message_count": message_count,
|
||||
"bot_user_id": workspace.bot_user_id,
|
||||
"is_admin": workspace.is_admin,
|
||||
}
|
||||
)
|
||||
return {"ok": True, "workspaces": workspaces, "total": len(workspaces)}
|
||||
|
||||
|
||||
def get_bot_user_id() -> str:
|
||||
return get_state().bot_user_id or DEFAULT_BOT_USER_ID
|
||||
|
||||
|
||||
def is_admin_mode() -> bool:
|
||||
return get_state().is_admin is True
|
||||
|
||||
|
||||
def generate_timestamp() -> str:
|
||||
global _last_timestamp_us
|
||||
now_us = int(time.time() * 1_000_000)
|
||||
if now_us <= _last_timestamp_us:
|
||||
now_us = _last_timestamp_us + 1
|
||||
_last_timestamp_us = now_us
|
||||
seconds, micros = divmod(now_us, 1_000_000)
|
||||
return f"{seconds}.{micros:06d}"
|
||||
|
||||
|
||||
def generate_channel_id() -> str:
|
||||
state = get_state()
|
||||
channel_id = f"C{state.counters.channelId:06d}"
|
||||
state.counters.channelId += 1
|
||||
return channel_id
|
||||
|
||||
|
||||
def generate_mpim_channel_id() -> str:
|
||||
state = get_state()
|
||||
channel_id = f"G{state.counters.channelId:06d}"
|
||||
state.counters.channelId += 1
|
||||
return channel_id
|
||||
|
||||
|
||||
def generate_file_id() -> str:
|
||||
state = get_state()
|
||||
state.counters.fileId += 1
|
||||
return f"F{state.counters.fileId:06d}"
|
||||
|
||||
|
||||
def get_channel(channel_id: str):
|
||||
return get_state().channels.get(channel_id)
|
||||
|
||||
|
||||
def get_user(user_id: str):
|
||||
return get_state().users.get(user_id)
|
||||
|
||||
|
||||
def get_channel_messages(channel_id: str) -> list[SlackMessage]:
|
||||
return get_state().messages.get(channel_id, [])
|
||||
|
||||
|
||||
def add_message(channel_id: str, message: SlackMessage | dict[str, Any]) -> None:
|
||||
new_message = SlackMessage.model_validate(message)
|
||||
|
||||
def _add(state: SlackState) -> None:
|
||||
if channel_id not in state.messages:
|
||||
state.messages[channel_id] = []
|
||||
state.messages[channel_id].append(new_message)
|
||||
|
||||
mutate_state(_add)
|
||||
|
||||
|
||||
def _refresh_thread_metadata(messages: list[SlackMessage], parent_ts: str) -> None:
|
||||
parent = next((message for message in messages if message.ts == parent_ts), None)
|
||||
if parent is None:
|
||||
return
|
||||
replies = sorted(
|
||||
(message for message in messages if message.thread_ts == parent_ts and message.ts != parent_ts),
|
||||
key=lambda message: float(message.ts),
|
||||
)
|
||||
if not replies:
|
||||
parent.reply_count = None
|
||||
parent.reply_users = None
|
||||
parent.reply_users_count = None
|
||||
parent.latest_reply = None
|
||||
return
|
||||
reply_users = sorted({reply.user for reply in replies if reply.user is not None})
|
||||
parent.reply_count = len(replies)
|
||||
parent.reply_users = reply_users
|
||||
parent.reply_users_count = len(reply_users)
|
||||
parent.latest_reply = replies[-1].ts
|
||||
|
||||
|
||||
def add_thread_reply(channel_id: str, parent_ts: str, message: SlackMessage | dict[str, Any]) -> None:
|
||||
new_message = SlackMessage.model_validate(message)
|
||||
|
||||
def _add_reply(state: SlackState) -> None:
|
||||
messages = state.messages.setdefault(channel_id, [])
|
||||
messages.append(new_message)
|
||||
_refresh_thread_metadata(messages, parent_ts)
|
||||
|
||||
mutate_state(_add_reply)
|
||||
|
||||
|
||||
def find_message(channel_id: str, ts: str) -> SlackMessage | None:
|
||||
return next((message for message in get_channel_messages(channel_id) if message.ts == ts), None)
|
||||
|
||||
|
||||
def delete_message(channel_id: str, ts: str) -> bool:
|
||||
messages = get_state().messages.get(channel_id)
|
||||
if not messages:
|
||||
return False
|
||||
if not any(message.ts == ts for message in messages):
|
||||
return False
|
||||
|
||||
def _delete(state: SlackState) -> bool:
|
||||
messages = state.messages.get(channel_id)
|
||||
if not messages:
|
||||
return False
|
||||
target = next((message for message in messages if message.ts == ts), None)
|
||||
if target is None:
|
||||
return False
|
||||
parent_ts = target.thread_ts if target.thread_ts and target.thread_ts != target.ts else None
|
||||
state.messages[channel_id] = [message for message in messages if message.ts != ts and message.thread_ts != ts]
|
||||
if parent_ts is not None:
|
||||
_refresh_thread_metadata(state.messages[channel_id], parent_ts)
|
||||
return True
|
||||
|
||||
return mutate_state(_delete)
|
||||
|
||||
|
||||
def update_message(channel_id: str, ts: str, updater: Callable[[SlackMessage], None]) -> bool:
|
||||
if find_message(channel_id, ts) is None:
|
||||
return False
|
||||
|
||||
def _update(state: SlackState) -> bool:
|
||||
message = next((message for message in state.messages.get(channel_id, []) if message.ts == ts), None)
|
||||
if message is None:
|
||||
return False
|
||||
updater(message)
|
||||
return True
|
||||
|
||||
return mutate_state(_update)
|
||||
|
||||
|
||||
def get_thread_replies(channel_id: str, thread_ts: str) -> list[SlackMessage]:
|
||||
messages = get_channel_messages(channel_id)
|
||||
parent = next((message for message in messages if message.ts == thread_ts), None)
|
||||
if parent is None:
|
||||
return []
|
||||
replies = [message for message in messages if message.thread_ts == thread_ts and message.ts != thread_ts]
|
||||
return sorted([parent, *replies], key=lambda message: float(message.ts))
|
||||
|
||||
|
||||
def all_files(channel_id: str) -> list[SlackFile]:
|
||||
files: list[SlackFile] = []
|
||||
for message in get_channel_messages(channel_id):
|
||||
files.extend(message.files or [])
|
||||
return files
|
||||
@@ -0,0 +1 @@
|
||||
"""Slack tool handler modules."""
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Channel tool handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from slack_mock.models import SlackChannel, SlackChannelTopic, SlackState
|
||||
from slack_mock.state import generate_channel_id, get_bot_user_id, get_channel, get_state, mutate_state
|
||||
from slack_mock.tools.common import (
|
||||
channel_payload,
|
||||
create_channel_object,
|
||||
is_direct_conversation,
|
||||
is_named_channel,
|
||||
model_dump,
|
||||
now_seconds,
|
||||
parse_cursor,
|
||||
)
|
||||
|
||||
|
||||
def list_channels(limit: int = 100, cursor: str | None = None) -> dict[str, Any]:
|
||||
state = get_state()
|
||||
normalized_limit = min(limit or 100, 200)
|
||||
start_index = parse_cursor(cursor)
|
||||
all_channels = [
|
||||
channel
|
||||
for channel in state.channels.values()
|
||||
if channel.is_member and not channel.is_archived and (channel.is_channel or channel.is_group)
|
||||
]
|
||||
paginated = all_channels[start_index : start_index + normalized_limit]
|
||||
next_index = start_index + normalized_limit
|
||||
return {
|
||||
"ok": True,
|
||||
"channels": model_dump(paginated),
|
||||
"response_metadata": {"next_cursor": str(next_index) if next_index < len(all_channels) else ""},
|
||||
}
|
||||
|
||||
|
||||
def create_channel(
|
||||
name: Annotated[str, Field(min_length=1, description="Channel name.")], is_private: bool = False
|
||||
) -> dict[str, Any]:
|
||||
state = get_state()
|
||||
normalized = name.strip().lower()
|
||||
if not normalized:
|
||||
return {"ok": False, "error": "invalid_name", "channel": {}}
|
||||
if any(is_named_channel(channel) and channel.name == normalized for channel in state.channels.values()):
|
||||
return {"ok": False, "error": "name_taken", "channel": {}}
|
||||
|
||||
def _create(state: SlackState) -> SlackChannel:
|
||||
channel_id = generate_channel_id()
|
||||
channel = create_channel_object(channel_id, normalized, is_private=is_private)
|
||||
state.channels[channel_id] = channel
|
||||
state.messages[channel_id] = []
|
||||
return channel
|
||||
|
||||
channel = mutate_state(_create)
|
||||
return {"ok": True, "channel": channel_payload(channel)}
|
||||
|
||||
|
||||
def archive_channel(channel_id: str) -> dict[str, Any]:
|
||||
channel = get_channel(channel_id)
|
||||
if channel is None:
|
||||
return {"ok": False, "error": "channel_not_found"}
|
||||
if is_direct_conversation(channel):
|
||||
return {"ok": False, "error": "method_not_supported_for_channel_type"}
|
||||
if channel.is_archived:
|
||||
return {"ok": False, "error": "already_archived"}
|
||||
|
||||
def _archive(state: SlackState) -> None:
|
||||
channel = state.channels[channel_id]
|
||||
channel.is_archived = True
|
||||
channel.updated = now_seconds()
|
||||
|
||||
mutate_state(_archive)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def rename_channel(
|
||||
channel_id: str, name: Annotated[str, Field(min_length=1, description="New channel name.")]
|
||||
) -> dict[str, Any]:
|
||||
state = get_state()
|
||||
channel = get_channel(channel_id)
|
||||
if channel is None:
|
||||
return {"ok": False, "error": "channel_not_found", "channel": {}}
|
||||
if is_direct_conversation(channel):
|
||||
return {"ok": False, "error": "method_not_supported_for_channel_type", "channel": {}}
|
||||
new_name = name.strip().lower()
|
||||
if not new_name:
|
||||
return {"ok": False, "error": "invalid_name", "channel": {}}
|
||||
if any(
|
||||
other.id != channel_id and is_named_channel(other) and other.name == new_name
|
||||
for other in state.channels.values()
|
||||
):
|
||||
return {"ok": False, "error": "name_taken", "channel": {}}
|
||||
|
||||
def _rename(state: SlackState) -> SlackChannel:
|
||||
channel = state.channels[channel_id]
|
||||
channel.previous_names = channel.previous_names or []
|
||||
channel.previous_names.append(channel.name)
|
||||
channel.name = new_name
|
||||
channel.name_normalized = new_name
|
||||
channel.updated = now_seconds()
|
||||
return channel
|
||||
|
||||
channel = mutate_state(_rename)
|
||||
return {"ok": True, "channel": channel_payload(channel)}
|
||||
|
||||
|
||||
def set_channel_topic(
|
||||
channel_id: str,
|
||||
topic: Annotated[str | None, Field(description="New channel topic.")] = None,
|
||||
purpose: Annotated[str | None, Field(description="New channel purpose.")] = None,
|
||||
) -> dict[str, Any]:
|
||||
channel = get_channel(channel_id)
|
||||
if channel is None:
|
||||
return {"ok": False, "error": "channel_not_found", "channel": {}}
|
||||
|
||||
def _set_topic(state: SlackState) -> SlackChannel:
|
||||
channel = state.channels[channel_id]
|
||||
now = now_seconds()
|
||||
if topic is not None:
|
||||
channel.topic = SlackChannelTopic(value=topic, creator=get_bot_user_id(), last_set=now)
|
||||
if purpose is not None:
|
||||
channel.purpose = SlackChannelTopic(value=purpose, creator=get_bot_user_id(), last_set=now)
|
||||
channel.updated = now
|
||||
return channel
|
||||
|
||||
channel = mutate_state(_set_topic)
|
||||
return {"ok": True, "channel": channel_payload(channel)}
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Shared Slack tool helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from slack_mock.models import (
|
||||
SlackChannel,
|
||||
SlackChannelId,
|
||||
SlackChannelTopic,
|
||||
SlackMessage,
|
||||
SlackTs,
|
||||
SlackUserId,
|
||||
SlackWorkspaceId,
|
||||
)
|
||||
from slack_mock.state import get_bot_user_id
|
||||
|
||||
ChannelIdArg = Annotated[SlackChannelId, Field(description="Slack channel, private channel, DM, or MPIM ID.")]
|
||||
CursorArg = Annotated[str, Field(description="Pagination cursor returned by a previous response.")]
|
||||
FileContentBase64Arg = Annotated[str, Field(description="Base64-encoded file content.")]
|
||||
FilenameArg = Annotated[str, Field(min_length=1, description="File name to store in Slack.")]
|
||||
LimitArg = Annotated[int, Field(ge=0, description="Maximum number of items to return.")]
|
||||
MessageTextArg = Annotated[str, Field(min_length=1, description="Message text.")]
|
||||
ReactionArg = Annotated[
|
||||
str, Field(min_length=1, description="Emoji reaction name, with or without surrounding colons.")
|
||||
]
|
||||
SearchQueryArg = Annotated[str, Field(min_length=1, description="Slack search query.")]
|
||||
TimestampArg = Annotated[SlackTs, Field(description="Slack message timestamp, such as 1700000001.000.")]
|
||||
UserIdArg = Annotated[SlackUserId, Field(description="Slack user ID.")]
|
||||
UserIdsArg = Annotated[
|
||||
list[SlackUserId],
|
||||
Field(min_length=2, description="Slack user IDs to include in a multi-party direct message."),
|
||||
]
|
||||
WorkspaceIdArg = Annotated[
|
||||
SlackWorkspaceId, Field(description="Slack workspace ID. Defaults to the default workspace.")
|
||||
]
|
||||
|
||||
|
||||
def model_dump(value: Any) -> Any:
|
||||
if isinstance(value, BaseModel):
|
||||
return value.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
if isinstance(value, list):
|
||||
return [model_dump(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: model_dump(item) for key, item in value.items() if item is not None}
|
||||
return value
|
||||
|
||||
|
||||
def empty_message() -> dict[str, str]:
|
||||
return {"type": "message", "text": "", "ts": ""}
|
||||
|
||||
|
||||
def now_seconds() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def parse_cursor(cursor: str | None) -> int:
|
||||
if not cursor:
|
||||
return 0
|
||||
try:
|
||||
return int(cursor)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def channel_payload(channel: SlackChannel) -> dict[str, Any]:
|
||||
return channel.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
def message_payload(message: SlackMessage) -> dict[str, Any]:
|
||||
return message.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
def create_channel_object(
|
||||
channel_id: str,
|
||||
name: str,
|
||||
*,
|
||||
is_private: bool,
|
||||
is_im: bool = False,
|
||||
is_mpim: bool = False,
|
||||
user_id: str | None = None,
|
||||
members: list[str] | None = None,
|
||||
) -> SlackChannel:
|
||||
now = now_seconds()
|
||||
bot_user_id = get_bot_user_id()
|
||||
is_direct = is_im or is_mpim
|
||||
return SlackChannel(
|
||||
id=channel_id,
|
||||
name=name,
|
||||
name_normalized=name,
|
||||
is_channel=not is_private and not is_direct,
|
||||
is_group=is_private and not is_direct,
|
||||
is_im=is_im,
|
||||
is_mpim=is_mpim,
|
||||
is_private=is_private,
|
||||
created=now,
|
||||
is_archived=False,
|
||||
is_general=False,
|
||||
unlinked=0,
|
||||
is_shared=False,
|
||||
is_org_shared=False,
|
||||
is_ext_shared=False,
|
||||
is_pending_ext_shared=False,
|
||||
pending_shared=[],
|
||||
pending_connected_team_ids=[],
|
||||
context_team_id="T_MOCK",
|
||||
updated=now,
|
||||
creator=bot_user_id,
|
||||
shared_team_ids=["T_MOCK"],
|
||||
is_member=True,
|
||||
user=user_id,
|
||||
members=members,
|
||||
num_members=len(members) if members else (1 if not is_direct else None),
|
||||
topic=SlackChannelTopic(value="", creator="", last_set=0) if not is_direct else None,
|
||||
purpose=SlackChannelTopic(value="", creator="", last_set=0) if not is_direct else None,
|
||||
previous_names=[] if not is_direct else None,
|
||||
)
|
||||
|
||||
|
||||
def is_named_channel(channel: SlackChannel) -> bool:
|
||||
return not channel.is_im and not channel.is_mpim and (channel.is_channel or channel.is_group)
|
||||
|
||||
|
||||
def is_direct_conversation(channel: SlackChannel) -> bool:
|
||||
return channel.is_im or channel.is_mpim
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Direct message tool handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from slack_mock.models import SlackChannel, SlackMessage, SlackMessageType, SlackState
|
||||
from slack_mock.state import (
|
||||
add_message,
|
||||
generate_mpim_channel_id,
|
||||
generate_timestamp,
|
||||
get_bot_user_id,
|
||||
get_state,
|
||||
get_user,
|
||||
mutate_state,
|
||||
)
|
||||
from slack_mock.tools.common import (
|
||||
channel_payload,
|
||||
create_channel_object,
|
||||
empty_message,
|
||||
is_direct_conversation,
|
||||
message_payload,
|
||||
model_dump,
|
||||
now_seconds,
|
||||
)
|
||||
|
||||
|
||||
def open_dm(user_id: str) -> dict[str, Any]:
|
||||
return _open_dm(user_id)
|
||||
|
||||
|
||||
def _open_dm(user_id: str) -> dict[str, Any]:
|
||||
state = get_state()
|
||||
user = get_user(user_id)
|
||||
if user is None:
|
||||
return {"ok": False, "error": "user_not_found", "channel": {}}
|
||||
for channel in state.channels.values():
|
||||
if channel.is_im and channel.user == user_id:
|
||||
return {"ok": True, "channel": channel_payload(channel)}
|
||||
|
||||
def _open(state: SlackState) -> SlackChannel:
|
||||
now = now_seconds()
|
||||
channel_id = f"D{str(now)[-6:]}{re.sub(r'[^a-zA-Z0-9]', '', user_id)}"
|
||||
channel = create_channel_object(channel_id, user.name, is_private=True, is_im=True, user_id=user_id)
|
||||
state.channels[channel_id] = channel
|
||||
state.messages[channel_id] = []
|
||||
return channel
|
||||
|
||||
channel = mutate_state(_open)
|
||||
return {"ok": True, "channel": channel_payload(channel)}
|
||||
|
||||
|
||||
def _mpim_members(user_ids: list[str]) -> tuple[list[str], list[str]]:
|
||||
unique_user_ids = list(dict.fromkeys(user_ids))
|
||||
bot_user_id = get_bot_user_id()
|
||||
other_user_ids = sorted(user_id for user_id in unique_user_ids if user_id != bot_user_id)
|
||||
return [bot_user_id, *other_user_ids], other_user_ids
|
||||
|
||||
|
||||
def _mpim_name(other_user_ids: list[str]) -> str:
|
||||
state = get_state()
|
||||
names = [state.users[user_id].name for user_id in other_user_ids]
|
||||
normalized_names = [re.sub(r"[^a-z0-9_-]+", "-", name.casefold()).strip("-") for name in names]
|
||||
return f"mpdm-{'--'.join(normalized_names)}-1"
|
||||
|
||||
|
||||
def open_mpim(user_ids: list[str]) -> dict[str, Any]:
|
||||
return _open_mpim(user_ids)
|
||||
|
||||
|
||||
def _open_mpim(user_ids: list[str]) -> dict[str, Any]:
|
||||
state = get_state()
|
||||
member_ids, other_user_ids = _mpim_members(user_ids)
|
||||
if len(other_user_ids) < 2:
|
||||
return {"ok": False, "error": "not_enough_users", "channel": {}}
|
||||
missing_user_ids = [user_id for user_id in other_user_ids if user_id not in state.users]
|
||||
if missing_user_ids:
|
||||
return {"ok": False, "error": "user_not_found", "channel": {}}
|
||||
|
||||
member_set = set(member_ids)
|
||||
channel_name = _mpim_name(other_user_ids)
|
||||
for channel in state.channels.values():
|
||||
if not channel.is_mpim:
|
||||
continue
|
||||
if channel.members and set(channel.members) == member_set:
|
||||
return {"ok": True, "channel": channel_payload(channel)}
|
||||
if channel.members is None and channel.name == channel_name:
|
||||
return {"ok": True, "channel": channel_payload(channel)}
|
||||
|
||||
def _open(state: SlackState) -> SlackChannel:
|
||||
channel_id = generate_mpim_channel_id()
|
||||
channel = create_channel_object(
|
||||
channel_id,
|
||||
channel_name,
|
||||
is_private=True,
|
||||
is_mpim=True,
|
||||
members=member_ids,
|
||||
)
|
||||
state.channels[channel_id] = channel
|
||||
state.messages[channel_id] = []
|
||||
return channel
|
||||
|
||||
channel = mutate_state(_open)
|
||||
return {"ok": True, "channel": channel_payload(channel)}
|
||||
|
||||
|
||||
def list_dms(limit: int = 20) -> dict[str, Any]:
|
||||
dms = sorted(
|
||||
(channel for channel in get_state().channels.values() if is_direct_conversation(channel)),
|
||||
key=lambda channel: channel.updated,
|
||||
reverse=True,
|
||||
)
|
||||
return {"ok": True, "channels": model_dump(dms[: limit or 20])}
|
||||
|
||||
|
||||
def send_dm(user_id: str, text: str) -> dict[str, Any]:
|
||||
opened = _open_dm(user_id)
|
||||
if not opened.get("ok"):
|
||||
return {"ok": False, "error": opened.get("error"), "channel": "", "ts": "", "message": empty_message()}
|
||||
channel_id = opened["channel"]["id"]
|
||||
ts = generate_timestamp()
|
||||
message = SlackMessage(type=SlackMessageType.MESSAGE, user=get_bot_user_id(), text=text, ts=ts, team="T_MOCK")
|
||||
add_message(channel_id, message)
|
||||
return {"ok": True, "channel": channel_id, "ts": ts, "message": message_payload(message)}
|
||||
|
||||
|
||||
def send_mpim(user_ids: list[str], text: str) -> dict[str, Any]:
|
||||
opened = _open_mpim(user_ids)
|
||||
if not opened.get("ok"):
|
||||
return {"ok": False, "error": opened.get("error"), "channel": "", "ts": "", "message": empty_message()}
|
||||
channel_id = opened["channel"]["id"]
|
||||
ts = generate_timestamp()
|
||||
message = SlackMessage(type=SlackMessageType.MESSAGE, user=get_bot_user_id(), text=text, ts=ts, team="T_MOCK")
|
||||
add_message(channel_id, message)
|
||||
return {"ok": True, "channel": channel_id, "ts": ts, "message": message_payload(message)}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""File tool handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from slack_mock.models import SlackFile, SlackFileMode, SlackMessage, SlackMessageSubtype, SlackMessageType
|
||||
from slack_mock.state import add_message, all_files, generate_file_id, generate_timestamp, get_bot_user_id, get_channel
|
||||
from slack_mock.tools.common import model_dump, now_seconds
|
||||
|
||||
_MIME_MAP = {
|
||||
".png": ("image/png", "png", "PNG"),
|
||||
".jpg": ("image/jpeg", "jpg", "JPEG"),
|
||||
".jpeg": ("image/jpeg", "jpeg", "JPEG"),
|
||||
".gif": ("image/gif", "gif", "GIF"),
|
||||
".pdf": ("application/pdf", "pdf", "PDF"),
|
||||
".txt": ("text/plain", "text", "Plain Text"),
|
||||
".csv": ("text/csv", "csv", "CSV"),
|
||||
".json": ("application/json", "javascript", "JSON"),
|
||||
".zip": ("application/zip", "zip", "Zip"),
|
||||
".doc": ("application/msword", "doc", "Word Document"),
|
||||
".docx": ("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx", "Word Document"),
|
||||
".xls": ("application/vnd.ms-excel", "xls", "Excel Spreadsheet"),
|
||||
".xlsx": ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx", "Excel Spreadsheet"),
|
||||
}
|
||||
|
||||
|
||||
def upload_file(
|
||||
channel_id: str,
|
||||
filename: str,
|
||||
content_base64: str,
|
||||
title: str | None = None,
|
||||
initial_comment: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
channel = get_channel(channel_id)
|
||||
if channel is None:
|
||||
return {"ok": False, "error": "channel_not_found", "file": {}}
|
||||
try:
|
||||
size = len(base64.b64decode(content_base64, validate=True))
|
||||
except Exception:
|
||||
return {"ok": False, "error": "invalid_base64", "file": {}}
|
||||
file_id = generate_file_id()
|
||||
now = now_seconds()
|
||||
ext = Path(filename).suffix.lower()
|
||||
mimetype, filetype, pretty_type = _MIME_MAP.get(
|
||||
ext, (mimetypes.guess_type(filename)[0] or "application/octet-stream", "binary", "Binary")
|
||||
)
|
||||
file = SlackFile(
|
||||
id=file_id,
|
||||
created=now,
|
||||
timestamp=now,
|
||||
name=filename,
|
||||
title=title or filename,
|
||||
mimetype=mimetype,
|
||||
filetype=filetype,
|
||||
pretty_type=pretty_type,
|
||||
user=get_bot_user_id(),
|
||||
size=size,
|
||||
mode=SlackFileMode.HOSTED,
|
||||
is_external=False,
|
||||
is_public=True,
|
||||
url_private=f"https://files.slack.com/files-pri/{file_id}/{filename}",
|
||||
url_private_download=f"https://files.slack.com/files-pri/{file_id}/download/{filename}",
|
||||
)
|
||||
ts = generate_timestamp()
|
||||
message = SlackMessage(
|
||||
type=SlackMessageType.MESSAGE,
|
||||
subtype=SlackMessageSubtype.FILE_SHARE,
|
||||
user=get_bot_user_id(),
|
||||
text=initial_comment or f"uploaded a file: {title or filename}",
|
||||
ts=ts,
|
||||
team=channel.context_team_id,
|
||||
files=[file],
|
||||
upload=True,
|
||||
)
|
||||
add_message(channel_id, message)
|
||||
return {"ok": True, "file": model_dump(file)}
|
||||
|
||||
|
||||
def list_files(channel_id: str, limit: int = 20) -> dict[str, Any]:
|
||||
if get_channel(channel_id) is None:
|
||||
return {"ok": False, "error": "channel_not_found", "files": [], "total": 0}
|
||||
files = sorted(all_files(channel_id), key=lambda file: file.created or 0, reverse=True)
|
||||
return {"ok": True, "files": model_dump(files[: limit or 20]), "total": len(files)}
|
||||
@@ -0,0 +1,455 @@
|
||||
"""Message tool handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from slack_mock.models import SlackMessage, SlackMessageEdited, SlackMessageType
|
||||
from slack_mock.state import (
|
||||
add_message,
|
||||
add_thread_reply,
|
||||
find_message,
|
||||
generate_timestamp,
|
||||
get_bot_user_id,
|
||||
get_channel,
|
||||
get_channel_messages,
|
||||
get_state,
|
||||
is_admin_mode,
|
||||
update_message,
|
||||
)
|
||||
from slack_mock.state import (
|
||||
delete_message as delete_message_from_state,
|
||||
)
|
||||
from slack_mock.state import (
|
||||
get_thread_replies as get_thread_replies_from_state,
|
||||
)
|
||||
from slack_mock.tools.common import empty_message, message_payload, model_dump
|
||||
|
||||
|
||||
def post_message(channel_id: str, text: str) -> dict[str, Any]:
|
||||
channel = get_channel(channel_id)
|
||||
if channel is None:
|
||||
return {"ok": False, "error": "channel_not_found", "channel": channel_id, "ts": "", "message": empty_message()}
|
||||
ts = generate_timestamp()
|
||||
message = SlackMessage(
|
||||
type=SlackMessageType.MESSAGE, user=get_bot_user_id(), text=text, ts=ts, team=channel.context_team_id
|
||||
)
|
||||
add_message(channel_id, message)
|
||||
return {"ok": True, "channel": channel_id, "ts": ts, "message": message_payload(message)}
|
||||
|
||||
|
||||
def reply_to_thread(channel_id: str, thread_ts: str, text: str) -> dict[str, Any]:
|
||||
channel = get_channel(channel_id)
|
||||
if channel is None:
|
||||
return {"ok": False, "error": "channel_not_found", "channel": channel_id, "ts": "", "message": empty_message()}
|
||||
parent = find_message(channel_id, thread_ts)
|
||||
if parent is None:
|
||||
return {"ok": False, "error": "thread_not_found", "channel": channel_id, "ts": "", "message": empty_message()}
|
||||
ts = generate_timestamp()
|
||||
message = SlackMessage(
|
||||
type=SlackMessageType.MESSAGE,
|
||||
user=get_bot_user_id(),
|
||||
text=text,
|
||||
ts=ts,
|
||||
thread_ts=thread_ts,
|
||||
parent_user_id=parent.user,
|
||||
team=channel.context_team_id,
|
||||
)
|
||||
add_thread_reply(channel_id, thread_ts, message)
|
||||
return {"ok": True, "channel": channel_id, "ts": ts, "message": message_payload(message)}
|
||||
|
||||
|
||||
def get_channel_history(channel_id: str, limit: int = 10) -> dict[str, Any]:
|
||||
if get_channel(channel_id) is None:
|
||||
return {"ok": False, "error": "channel_not_found", "messages": [], "has_more": False}
|
||||
all_messages = get_channel_messages(channel_id)
|
||||
top_level = [message for message in all_messages if not message.thread_ts or message.thread_ts == message.ts]
|
||||
sorted_messages = sorted(top_level, key=lambda message: float(message.ts), reverse=True)
|
||||
limited = sorted_messages[: limit or 10]
|
||||
return {"ok": True, "messages": model_dump(limited), "has_more": len(sorted_messages) > (limit or 10)}
|
||||
|
||||
|
||||
def get_thread_replies(channel_id: str, thread_ts: str) -> dict[str, Any]:
|
||||
if get_channel(channel_id) is None:
|
||||
return {"ok": False, "error": "channel_not_found", "messages": [], "has_more": False}
|
||||
messages = get_thread_replies_from_state(channel_id, thread_ts)
|
||||
if not messages:
|
||||
return {"ok": False, "error": "thread_not_found", "messages": [], "has_more": False}
|
||||
return {"ok": True, "messages": model_dump(messages), "has_more": False}
|
||||
|
||||
|
||||
def _normalize_filter_value(value: str) -> str:
|
||||
return value.strip().lstrip("#@").lower()
|
||||
|
||||
|
||||
def _parse_date_range(value: str) -> tuple[float, float] | None:
|
||||
trimmed = value.strip()
|
||||
try:
|
||||
if re.match(r"^\d{4}$", trimmed):
|
||||
year = int(trimmed)
|
||||
start = datetime(year, 1, 1, tzinfo=UTC)
|
||||
end = datetime(year + 1, 1, 1, tzinfo=UTC)
|
||||
elif re.match(r"^\d{4}-\d{2}$", trimmed):
|
||||
year, month = [int(part) for part in trimmed.split("-")]
|
||||
start = datetime(year, month, 1, tzinfo=UTC)
|
||||
end = datetime(year + (month // 12), (month % 12) + 1, 1, tzinfo=UTC)
|
||||
elif re.match(r"^\d{4}-\d{2}-\d{2}$", trimmed):
|
||||
year, month, day = [int(part) for part in trimmed.split("-")]
|
||||
start = datetime(year, month, day, tzinfo=UTC)
|
||||
end = start + timedelta(days=1)
|
||||
else:
|
||||
parsed = datetime.fromisoformat(trimmed.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
start = parsed
|
||||
end = parsed + timedelta(days=1)
|
||||
except ValueError:
|
||||
return None
|
||||
return start.timestamp(), end.timestamp()
|
||||
|
||||
|
||||
def _parse_date_start(value: str) -> float | None:
|
||||
trimmed = value.strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
if re.match(r"^\d+(\.\d+)?$", trimmed):
|
||||
return float(trimmed)
|
||||
date_range = _parse_date_range(trimmed)
|
||||
return date_range[0] if date_range else None
|
||||
|
||||
|
||||
def _parse_search_query(query: str) -> dict[str, Any]:
|
||||
parsed: dict[str, Any] = {
|
||||
"textTokens": [],
|
||||
"channelFilters": [],
|
||||
"userFilters": [],
|
||||
"hasFilters": [],
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
def add_warning(message: str) -> None:
|
||||
if message not in parsed["warnings"]:
|
||||
parsed["warnings"].append(message)
|
||||
|
||||
for match in re.finditer(r'"([^"]+)"|\S+', query):
|
||||
was_quoted = match.group(1) is not None
|
||||
raw = (match.group(1) if was_quoted else match.group(0)).strip()
|
||||
if not raw:
|
||||
continue
|
||||
token = raw.lower()
|
||||
if was_quoted:
|
||||
parsed["textTokens"].append(token)
|
||||
elif token.startswith("in:"):
|
||||
value = _normalize_filter_value(raw[3:])
|
||||
if value:
|
||||
parsed["channelFilters"].append(value)
|
||||
else:
|
||||
add_warning("Empty in: filter was ignored.")
|
||||
elif token.startswith("from:"):
|
||||
raw_value = raw[5:].strip()
|
||||
value = _normalize_filter_value(raw_value)
|
||||
if value == "me":
|
||||
add_warning("from:me is unsupported because caller identity is not available; it will match no users.")
|
||||
if value:
|
||||
parsed["userFilters"].append({"value": value, "handle_only": raw_value.startswith("@")})
|
||||
else:
|
||||
add_warning("Empty from: filter was ignored.")
|
||||
elif token.startswith("after:"):
|
||||
bound = _parse_date_start(raw[6:])
|
||||
if bound is not None:
|
||||
parsed["after"] = max(parsed.get("after", float("-inf")), bound)
|
||||
else:
|
||||
add_warning(f"Invalid after: date '{raw[6:]}'. Use a Unix timestamp or YYYY[-MM[-DD]].")
|
||||
elif token.startswith("before:"):
|
||||
bound = _parse_date_start(raw[7:])
|
||||
if bound is not None:
|
||||
parsed["before"] = min(parsed.get("before", float("inf")), bound)
|
||||
else:
|
||||
add_warning(f"Invalid before: date '{raw[7:]}'. Use a Unix timestamp or YYYY[-MM[-DD]].")
|
||||
elif token.startswith("during:"):
|
||||
date_range = _parse_date_range(raw[7:])
|
||||
if date_range:
|
||||
parsed["after"] = max(parsed.get("after", float("-inf")), date_range[0])
|
||||
parsed["before"] = min(parsed.get("before", float("inf")), date_range[1])
|
||||
else:
|
||||
add_warning(f"Invalid during: date '{raw[7:]}'. Use YYYY, YYYY-MM, YYYY-MM-DD, or a parseable date.")
|
||||
elif token.startswith("has:"):
|
||||
value = _normalize_filter_value(raw[4:])
|
||||
if value:
|
||||
if value not in {"link", "reaction", "star", "pin", "pinned"}:
|
||||
add_warning(f"Unsupported has: value '{value}'. Supported values: link, reaction, star, pin.")
|
||||
parsed["hasFilters"].append(value)
|
||||
else:
|
||||
add_warning("Empty has: filter was ignored.")
|
||||
elif re.match(r"^[a-z_]+:", raw, flags=re.I) and not re.match(r"^(https?|mailto):", raw, flags=re.I):
|
||||
operator = raw[: raw.index(":") + 1]
|
||||
add_warning(f"Unsupported Slack search operator '{operator}'; it will be treated as a text token.")
|
||||
parsed["textTokens"].append(token)
|
||||
else:
|
||||
parsed["textTokens"].append(token)
|
||||
return parsed
|
||||
|
||||
|
||||
def _channel_matches_filter(channel_id: str, channel_name: str, filter_value: str) -> bool:
|
||||
return channel_id.lower() == filter_value or channel_name.lower() == filter_value
|
||||
|
||||
|
||||
def _user_matches_filter(user_id: str | None, user_name: str, filter_value: dict[str, Any]) -> bool:
|
||||
value = filter_value["value"]
|
||||
if not user_id or value == "me":
|
||||
return False
|
||||
user = get_state().users.get(user_id)
|
||||
if value == user_id.lower():
|
||||
return True
|
||||
if filter_value.get("handle_only"):
|
||||
return bool(user and user.name.casefold() == value)
|
||||
substrings = [
|
||||
user.name if user else None,
|
||||
user.real_name if user else None,
|
||||
user.profile.display_name if user else None,
|
||||
user.profile.real_name if user else None,
|
||||
user.profile.email if user else None,
|
||||
user_name,
|
||||
]
|
||||
lowered = [str(value).lower() for value in substrings if value]
|
||||
return any(filter_value["value"] in value for value in lowered)
|
||||
|
||||
|
||||
def _normalize_search_limit(limit: int | None, warnings: list[str]) -> int:
|
||||
value = 20 if limit is None else limit
|
||||
if value > 100:
|
||||
warnings.append("limit exceeds the maximum of 100; using 100.")
|
||||
return 100
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_search_cursor(cursor: str | None, warnings: list[str]) -> int:
|
||||
if not cursor:
|
||||
return 0
|
||||
try:
|
||||
parsed = int(cursor)
|
||||
except ValueError:
|
||||
warnings.append(f"Invalid cursor '{cursor}'; using the first page.")
|
||||
return 0
|
||||
if parsed < 0:
|
||||
warnings.append("cursor must be non-negative; using the first page.")
|
||||
return 0
|
||||
return parsed
|
||||
|
||||
|
||||
def _message_has_filter(message: SlackMessage, channel_id: str, filter_value: str) -> bool:
|
||||
if filter_value == "reaction":
|
||||
return bool(message.reactions)
|
||||
if filter_value == "star":
|
||||
return bool(message.is_starred)
|
||||
if filter_value in {"pin", "pinned"}:
|
||||
return bool(message.pinned_to and channel_id in message.pinned_to)
|
||||
if filter_value == "link":
|
||||
return bool(
|
||||
any(
|
||||
attachment.title_link or attachment.author_link or attachment.image_url or attachment.thumb_url
|
||||
for attachment in message.attachments or []
|
||||
)
|
||||
or any(file.url_private or file.permalink for file in message.files or [])
|
||||
or re.search(r"https?://", message.text, flags=re.I)
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def search_messages(
|
||||
query: str,
|
||||
channel_id: str | None = None,
|
||||
limit: Annotated[
|
||||
int | None,
|
||||
Field(ge=0, description="Maximum number of matches to return. Values above 100 are capped."),
|
||||
] = None,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
state = get_state()
|
||||
if not query or not query.strip():
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "missing_query",
|
||||
"messages": {"matches": [], "total": 0},
|
||||
"response_metadata": {"warnings": []},
|
||||
}
|
||||
|
||||
parsed = _parse_search_query(query)
|
||||
warnings = parsed["warnings"]
|
||||
has_structured = (
|
||||
any(parsed[key] for key in ("channelFilters", "userFilters", "hasFilters"))
|
||||
or "after" in parsed
|
||||
or "before" in parsed
|
||||
)
|
||||
if not parsed["textTokens"] and not has_structured:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "missing_query",
|
||||
"messages": {"matches": [], "total": 0},
|
||||
"response_metadata": {"warnings": warnings},
|
||||
}
|
||||
|
||||
normalized_limit = _normalize_search_limit(limit, warnings)
|
||||
start_index = _normalize_search_cursor(cursor, warnings)
|
||||
|
||||
if channel_id:
|
||||
if channel_id not in state.channels:
|
||||
return {"ok": False, "error": "channel_not_found", "messages": {"matches": [], "total": 0}}
|
||||
channel_ids = [channel_id]
|
||||
else:
|
||||
channel_ids = list(state.channels)
|
||||
|
||||
if parsed["channelFilters"]:
|
||||
if channel_id:
|
||||
scoped = state.channels[channel_id]
|
||||
conflicts = all(
|
||||
not _channel_matches_filter(channel_id, scoped.name, filter_value)
|
||||
for filter_value in parsed["channelFilters"]
|
||||
)
|
||||
if conflicts:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "channel_scope_conflict",
|
||||
"messages": {"matches": [], "total": 0},
|
||||
"response_metadata": {"warnings": warnings},
|
||||
}
|
||||
channel_ids = [
|
||||
cid
|
||||
for cid in channel_ids
|
||||
if any(
|
||||
_channel_matches_filter(cid, state.channels[cid].name, filter_value)
|
||||
for filter_value in parsed["channelFilters"]
|
||||
)
|
||||
]
|
||||
|
||||
matches: list[dict[str, Any]] = []
|
||||
for cid in channel_ids:
|
||||
channel = state.channels[cid]
|
||||
for message in get_channel_messages(cid):
|
||||
user = state.users.get(message.user) if message.user else None
|
||||
display_name = user.profile.display_name if user else ""
|
||||
real_name = user.real_name if user else ""
|
||||
user_name = user.name if user else ""
|
||||
rendered_user_name = display_name or real_name or user_name or message.user or "Unknown"
|
||||
haystack = "\n".join(
|
||||
value for value in [message.text, display_name or "", real_name or "", user_name or ""] if value
|
||||
).lower()
|
||||
ts = float(message.ts)
|
||||
if "after" in parsed and ts < parsed["after"]:
|
||||
continue
|
||||
if "before" in parsed and ts >= parsed["before"]:
|
||||
continue
|
||||
if parsed["userFilters"] and not any(
|
||||
_user_matches_filter(
|
||||
message.user, display_name or real_name or user_name or message.user or "", filter_value
|
||||
)
|
||||
for filter_value in parsed["userFilters"]
|
||||
):
|
||||
continue
|
||||
if parsed["hasFilters"] and not all(
|
||||
_message_has_filter(message, cid, filter_value) for filter_value in parsed["hasFilters"]
|
||||
):
|
||||
continue
|
||||
if all(token in haystack for token in parsed["textTokens"]):
|
||||
matches.append(
|
||||
{
|
||||
"channelId": cid,
|
||||
"channelName": channel.name,
|
||||
"message": message,
|
||||
"displayName": display_name or real_name or user_name or message.user or "Unknown",
|
||||
"username": user_name,
|
||||
"userName": rendered_user_name,
|
||||
}
|
||||
)
|
||||
|
||||
matches.sort(key=lambda item: float(item["message"].ts), reverse=True)
|
||||
total = len(matches)
|
||||
paginated = matches[start_index : start_index + normalized_limit]
|
||||
next_index = start_index + normalized_limit
|
||||
return {
|
||||
"ok": True,
|
||||
"messages": {
|
||||
"matches": [
|
||||
{
|
||||
"channel": {"id": item["channelId"], "name": item["channelName"]},
|
||||
"ts": item["message"].ts,
|
||||
"text": item["message"].text,
|
||||
"user": item["message"].user or "",
|
||||
"username": item["username"],
|
||||
"display_name": item["displayName"],
|
||||
"user_name": item["userName"],
|
||||
"thread_ts": item["message"].thread_ts,
|
||||
"reply_count": item["message"].reply_count,
|
||||
"reactions": model_dump(item["message"].reactions),
|
||||
"permalink": item["message"].permalink,
|
||||
}
|
||||
for item in paginated
|
||||
],
|
||||
"total": total,
|
||||
},
|
||||
"response_metadata": {"next_cursor": str(next_index) if next_index < total else "", "warnings": warnings},
|
||||
}
|
||||
|
||||
|
||||
def edit_message(channel_id: str, ts: str, text: str) -> dict[str, Any]:
|
||||
if get_channel(channel_id) is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "channel_not_found",
|
||||
"channel": channel_id,
|
||||
"ts": ts,
|
||||
"text": "",
|
||||
"message": empty_message(),
|
||||
}
|
||||
message = find_message(channel_id, ts)
|
||||
if message is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "message_not_found",
|
||||
"channel": channel_id,
|
||||
"ts": ts,
|
||||
"text": "",
|
||||
"message": empty_message(),
|
||||
}
|
||||
if not is_admin_mode() and message.user and message.user != get_bot_user_id():
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "cant_update_message",
|
||||
"channel": channel_id,
|
||||
"ts": ts,
|
||||
"text": "",
|
||||
"message": empty_message(),
|
||||
}
|
||||
|
||||
def _edit(msg: SlackMessage) -> None:
|
||||
msg.text = text
|
||||
msg.edited = SlackMessageEdited(user=get_bot_user_id(), ts=generate_timestamp())
|
||||
|
||||
update_message(channel_id, ts, _edit)
|
||||
updated = find_message(channel_id, ts)
|
||||
if updated is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "message_not_found",
|
||||
"channel": channel_id,
|
||||
"ts": ts,
|
||||
"text": "",
|
||||
"message": empty_message(),
|
||||
}
|
||||
return {"ok": True, "channel": channel_id, "ts": ts, "text": updated.text, "message": message_payload(updated)}
|
||||
|
||||
|
||||
def delete_message(channel_id: str, ts: str) -> dict[str, Any]:
|
||||
if get_channel(channel_id) is None:
|
||||
return {"ok": False, "error": "channel_not_found", "channel": channel_id, "ts": ts}
|
||||
message = find_message(channel_id, ts)
|
||||
if message is None:
|
||||
return {"ok": False, "error": "message_not_found", "channel": channel_id, "ts": ts}
|
||||
if not is_admin_mode() and message.user and message.user != get_bot_user_id():
|
||||
return {"ok": False, "error": "cant_delete_message", "channel": channel_id, "ts": ts}
|
||||
delete_message_from_state(channel_id, ts)
|
||||
return {"ok": True, "channel": channel_id, "ts": ts}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Reaction and pin tool handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from slack_mock.models import SlackMessage, SlackReaction
|
||||
from slack_mock.state import (
|
||||
find_message,
|
||||
get_bot_user_id,
|
||||
get_channel,
|
||||
get_channel_messages,
|
||||
update_message,
|
||||
)
|
||||
from slack_mock.tools.common import message_payload, now_seconds
|
||||
|
||||
|
||||
def add_reaction(channel_id: str, timestamp: str, reaction: str) -> dict[str, Any]:
|
||||
if get_channel(channel_id) is None:
|
||||
return {"ok": False, "error": "channel_not_found"}
|
||||
if find_message(channel_id, timestamp) is None:
|
||||
return {"ok": False, "error": "message_not_found"}
|
||||
reaction_name = reaction.strip(":")
|
||||
|
||||
def _add(msg: SlackMessage) -> None:
|
||||
msg.reactions = msg.reactions or []
|
||||
existing = next((item for item in msg.reactions if item.name == reaction_name), None)
|
||||
if existing is not None:
|
||||
if get_bot_user_id() not in existing.users:
|
||||
existing.users.append(get_bot_user_id())
|
||||
existing.count += 1
|
||||
else:
|
||||
msg.reactions.append(SlackReaction(name=reaction_name, users=[get_bot_user_id()], count=1))
|
||||
|
||||
return {"ok": update_message(channel_id, timestamp, _add)}
|
||||
|
||||
|
||||
def pin_message(channel_id: str, timestamp: str) -> dict[str, Any]:
|
||||
if get_channel(channel_id) is None:
|
||||
return {"ok": False, "error": "channel_not_found"}
|
||||
message = find_message(channel_id, timestamp)
|
||||
if message is None:
|
||||
return {"ok": False, "error": "message_not_found"}
|
||||
if message.pinned_to and channel_id in message.pinned_to:
|
||||
return {"ok": False, "error": "already_pinned"}
|
||||
|
||||
def _pin(msg: SlackMessage) -> None:
|
||||
msg.pinned_to = msg.pinned_to or []
|
||||
msg.pinned_to.append(channel_id)
|
||||
msg.pinned_info = msg.pinned_info or {}
|
||||
msg.pinned_info[channel_id] = {"pinned_by": get_bot_user_id(), "pinned_ts": now_seconds()}
|
||||
|
||||
update_message(channel_id, timestamp, _pin)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def unpin_message(channel_id: str, timestamp: str) -> dict[str, Any]:
|
||||
if get_channel(channel_id) is None:
|
||||
return {"ok": False, "error": "channel_not_found"}
|
||||
message = find_message(channel_id, timestamp)
|
||||
if message is None:
|
||||
return {"ok": False, "error": "message_not_found"}
|
||||
if not message.pinned_to or channel_id not in message.pinned_to:
|
||||
return {"ok": False, "error": "not_pinned"}
|
||||
|
||||
def _unpin(msg: SlackMessage) -> None:
|
||||
msg.pinned_to = [pinned_id for pinned_id in msg.pinned_to or [] if pinned_id != channel_id] or None
|
||||
if msg.pinned_info:
|
||||
msg.pinned_info.pop(channel_id, None)
|
||||
|
||||
update_message(channel_id, timestamp, _unpin)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def list_pins(channel_id: str) -> dict[str, Any]:
|
||||
if get_channel(channel_id) is None:
|
||||
return {"ok": False, "error": "channel_not_found", "items": []}
|
||||
items = []
|
||||
for message in get_channel_messages(channel_id):
|
||||
if message.pinned_to and channel_id in message.pinned_to:
|
||||
info = (message.pinned_info or {}).get(channel_id, {})
|
||||
items.append(
|
||||
{
|
||||
"type": "message",
|
||||
"channel": channel_id,
|
||||
"message": message_payload(message),
|
||||
"created": info.get("pinned_ts", 0),
|
||||
"created_by": info.get("pinned_by", get_bot_user_id()),
|
||||
}
|
||||
)
|
||||
items.sort(key=lambda item: item["created"], reverse=True)
|
||||
return {"ok": True, "items": items}
|
||||
@@ -0,0 +1,25 @@
|
||||
"""State import/export tool handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from slack_mock.models import SlackMockState, SlackState, SlackWorkspacesState
|
||||
from slack_mock.state import list_workspaces as list_loaded_workspaces
|
||||
from slack_mock.state import state_from_json, state_to_json
|
||||
|
||||
|
||||
def export_state() -> SlackMockState:
|
||||
exported = state_to_json()
|
||||
if "workspaces" in exported:
|
||||
return SlackWorkspacesState.model_validate(exported)
|
||||
return SlackState.model_validate(exported)
|
||||
|
||||
|
||||
def import_state(state: dict[str, Any]) -> dict[str, bool]:
|
||||
state_from_json(state)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def list_workspaces() -> dict[str, Any]:
|
||||
return list_loaded_workspaces()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""User tool handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from slack_mock.models import SlackState
|
||||
from slack_mock.state import get_bot_user_id, get_state, get_user, is_admin_mode, mutate_state
|
||||
from slack_mock.tools.common import model_dump, now_seconds, parse_cursor
|
||||
|
||||
|
||||
def get_users(cursor: str | None = None, limit: int = 100) -> dict[str, Any]:
|
||||
all_users = list(get_state().users.values())
|
||||
normalized_limit = min(limit or 100, 200)
|
||||
start_index = parse_cursor(cursor)
|
||||
paginated = all_users[start_index : start_index + normalized_limit]
|
||||
next_index = start_index + normalized_limit
|
||||
return {
|
||||
"ok": True,
|
||||
"members": model_dump(paginated),
|
||||
"response_metadata": {"next_cursor": str(next_index) if next_index < len(all_users) else ""},
|
||||
}
|
||||
|
||||
|
||||
def get_user_profile(user_id: str) -> dict[str, Any]:
|
||||
user = get_user(user_id)
|
||||
if user is None:
|
||||
return {"ok": False, "error": "user_not_found", "profile": {}}
|
||||
return {"ok": True, "profile": user.profile.model_dump(mode="json", by_alias=True, exclude_none=True)}
|
||||
|
||||
|
||||
def set_user_status(
|
||||
status_text: str,
|
||||
status_emoji: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
target_user_id = user_id or get_bot_user_id()
|
||||
user = get_user(target_user_id)
|
||||
if user is None:
|
||||
return {"ok": False, "error": "user_not_found", "profile": {}}
|
||||
if target_user_id != get_bot_user_id() and not is_admin_mode():
|
||||
return {"ok": False, "error": "cant_update_profile", "profile": {}}
|
||||
|
||||
def _set_status(state: SlackState):
|
||||
user = state.users[target_user_id]
|
||||
user.profile.status_text = status_text
|
||||
if status_emoji is not None:
|
||||
user.profile.status_emoji = status_emoji
|
||||
return user.profile
|
||||
|
||||
profile = mutate_state(_set_status)
|
||||
return {"ok": True, "profile": profile.model_dump(mode="json", by_alias=True, exclude_none=True)}
|
||||
|
||||
|
||||
def get_user_presence(user_id: str) -> dict[str, Any]:
|
||||
user = get_user(user_id)
|
||||
if user is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "user_not_found",
|
||||
"presence": "",
|
||||
"online": False,
|
||||
"auto_away": False,
|
||||
"manual_away": False,
|
||||
}
|
||||
is_active = not user.deleted
|
||||
return {
|
||||
"ok": True,
|
||||
"presence": "active" if is_active else "away",
|
||||
"online": is_active,
|
||||
"auto_away": False,
|
||||
"manual_away": not is_active,
|
||||
"connection_count": 1 if is_active else 0,
|
||||
"last_activity": now_seconds(),
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Slack</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1d21; color: #d1d2d3; display: flex; height: 100vh; overflow: hidden; }
|
||||
.channels { width: 240px; min-width: 240px; background: #19171d; border-right: 1px solid #383838; display: flex; flex-direction: column; }
|
||||
.channels-header { padding: 14px 16px; border-bottom: 1px solid #383838; }
|
||||
.channels-header h2 { font-size: 15px; color: #fff; font-weight: 700; }
|
||||
.channel-list { flex: 1; overflow-y: auto; padding: 8px 0; }
|
||||
.channel-item { display: flex; align-items: center; gap: 6px; padding: 4px 16px; cursor: pointer; font-size: 13px; color: #b0b0b0; }
|
||||
.channel-item:hover { background: #27242c; }
|
||||
.channel-item.active { background: #1264a3; color: #fff; }
|
||||
.channel-item .hash { color: #6b6b6b; font-size: 14px; font-weight: 300; }
|
||||
.channel-item.active .hash { color: rgba(255,255,255,0.7); }
|
||||
.channel-item .badge { margin-left: auto; background: #cd2553; color: #fff; border-radius: 10px; padding: 0 6px; font-size: 11px; font-weight: 700; }
|
||||
.message-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.message-header { padding: 12px 20px; border-bottom: 1px solid #383838; background: #1a1d21; }
|
||||
.message-header h2 { font-size: 15px; color: #fff; font-weight: 700; }
|
||||
.message-header p { font-size: 12px; color: #6b6b6b; margin-top: 2px; }
|
||||
.messages { flex: 1; overflow-y: auto; padding: 16px 20px; display: flex; flex-direction: column; }
|
||||
.msg { display: flex; gap: 10px; padding: 6px 0; }
|
||||
.msg:hover { background: rgba(255,255,255,0.02); }
|
||||
.msg-avatar { width: 36px; height: 36px; border-radius: 6px; background: #3e313c; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; color: #e8912d; flex-shrink: 0; }
|
||||
.msg-content { flex: 1; min-width: 0; }
|
||||
.msg-author { font-size: 13px; font-weight: 700; color: #fff; display: inline; }
|
||||
.msg-time { font-size: 11px; color: #616061; margin-left: 8px; }
|
||||
.msg-text { font-size: 13px; line-height: 1.5; margin-top: 2px; word-wrap: break-word; white-space: pre-wrap; }
|
||||
.msg-reactions { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
|
||||
.msg-reaction { background: #2c2c35; border: 1px solid #3e3e48; border-radius: 12px; padding: 2px 8px; font-size: 11px; }
|
||||
.msg-files { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
||||
.msg-file { background: #2c2c35; border: 1px solid #3e3e48; border-radius: 6px; padding: 3px 9px; font-size: 12px; }
|
||||
.msg-thread { margin-top: 4px; font-size: 12px; color: #36c5f0; cursor: pointer; }
|
||||
.msg-thread:hover { text-decoration: underline; }
|
||||
.thread-panel { width: 360px; min-width: 360px; border-left: 1px solid #383838; background: #1a1d21; display: none; flex-direction: column; }
|
||||
.thread-panel.open { display: flex; }
|
||||
.thread-header { padding: 12px 16px; border-bottom: 1px solid #383838; display: flex; justify-content: space-between; align-items: center; }
|
||||
.thread-header h3 { font-size: 14px; color: #fff; font-weight: 700; }
|
||||
.thread-close { background: none; border: none; color: #6b6b6b; font-size: 18px; cursor: pointer; }
|
||||
.thread-messages { flex: 1; overflow-y: auto; padding: 12px 16px; }
|
||||
.users-panel { display: none; flex: 1; flex-direction: column; }
|
||||
.users-panel.open { display: flex; }
|
||||
.users-header { padding: 12px 20px; border-bottom: 1px solid #383838; }
|
||||
.users-header h2 { font-size: 15px; color: #fff; }
|
||||
.users-list { flex: 1; overflow-y: auto; padding: 8px 20px; }
|
||||
.user-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #27242c; }
|
||||
.user-avatar { width: 32px; height: 32px; border-radius: 6px; background: #3e313c; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; color: #4a9; flex-shrink: 0; }
|
||||
.user-info { flex: 1; }
|
||||
.user-name { font-size: 13px; font-weight: 600; color: #fff; }
|
||||
.user-title { font-size: 12px; color: #6b6b6b; }
|
||||
.empty-msg { text-align: center; padding: 40px; color: #6b6b6b; font-size: 14px; }
|
||||
.channels-nav { display: flex; border-top: 1px solid #383838; }
|
||||
.channels-nav button { flex: 1; padding: 10px; background: none; border: none; color: #6b6b6b; font-size: 12px; cursor: pointer; font-weight: 600; }
|
||||
.channels-nav button.active { color: #fff; background: #27242c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="channels">
|
||||
<div class="channels-header"><h2>Channels</h2></div>
|
||||
<div id="channel-list" class="channel-list"></div>
|
||||
<div class="channels-nav">
|
||||
<button class="active" onclick="showPanel(event, 'messages')">Messages</button>
|
||||
<button onclick="showPanel(event, 'users')">People</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="message-pane" class="message-pane">
|
||||
<div class="message-header">
|
||||
<h2 id="channel-name">Select a channel</h2>
|
||||
<p id="channel-topic"></p>
|
||||
</div>
|
||||
<div id="messages" class="messages">
|
||||
<div class="empty-msg">Select a channel to view messages</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="users-panel" class="users-panel">
|
||||
<div class="users-header"><h2>People</h2></div>
|
||||
<div id="users-list" class="users-list"></div>
|
||||
</div>
|
||||
<div id="thread-panel" class="thread-panel">
|
||||
<div class="thread-header">
|
||||
<h3>Thread</h3>
|
||||
<button class="thread-close" onclick="closeThread()">×</button>
|
||||
</div>
|
||||
<div id="thread-messages" class="thread-messages"></div>
|
||||
</div>
|
||||
<script>
|
||||
let channels = [];
|
||||
let currentChannel = null;
|
||||
let usersMap = {};
|
||||
const base = window.location.pathname.replace(/\/$/, '');
|
||||
|
||||
async function fetchJSON(path) {
|
||||
const response = await fetch(base + path);
|
||||
if (!response.ok) throw new Error(path + ' returned ' + response.status);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const AVATAR_COLORS = ['#e8912d','#4a9c6d','#c74b50','#3d7aed','#9b59b6','#1abc9c','#e67e22','#2ecc71'];
|
||||
function avatarColor(name) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h);
|
||||
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length];
|
||||
}
|
||||
function initials(name) {
|
||||
if (!name) return '?';
|
||||
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
}
|
||||
function esc(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
const node = document.createElement('div');
|
||||
node.textContent = String(value);
|
||||
return node.innerHTML;
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
n = Number(n) || 0;
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
|
||||
return (n / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const [chData, userData] = await Promise.all([
|
||||
fetchJSON('/api/channels'),
|
||||
fetchJSON('/api/users'),
|
||||
]);
|
||||
channels = chData.channels || [];
|
||||
(userData.users || []).forEach(user => { usersMap[user.id] = user; });
|
||||
|
||||
const list = document.getElementById('channel-list');
|
||||
list.innerHTML = channels.map(channel =>
|
||||
'<div class="channel-item" data-id="' + esc(channel.id) + '" onclick="selectChannel(\'' + esc(channel.id) + '\')">' +
|
||||
'<span class="hash">#</span>' + esc(channel.name) +
|
||||
(channel.messageCount ? '<span class="badge">' + channel.messageCount + '</span>' : '') +
|
||||
'</div>'
|
||||
).join('');
|
||||
|
||||
if (channels.length > 0) selectChannel(channels[0].id);
|
||||
}
|
||||
|
||||
async function selectChannel(id) {
|
||||
currentChannel = channels.find(channel => channel.id === id);
|
||||
if (!currentChannel) return;
|
||||
|
||||
document.querySelectorAll('.channel-item').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.id === id);
|
||||
});
|
||||
document.getElementById('channel-name').textContent = '# ' + currentChannel.name;
|
||||
document.getElementById('channel-topic').textContent = currentChannel.topic || '';
|
||||
|
||||
const data = await fetchJSON('/api/channels/' + encodeURIComponent(id) + '/messages');
|
||||
const el = document.getElementById('messages');
|
||||
if (!data.messages.length) {
|
||||
el.innerHTML = '<div class="empty-msg">No messages in this channel</div>';
|
||||
return;
|
||||
}
|
||||
const messages = data.messages.slice().reverse();
|
||||
el.innerHTML = messages.map(message => renderMessage(message)).join('');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
closeThread();
|
||||
}
|
||||
|
||||
function renderMessage(message) {
|
||||
const color = avatarColor(message.user_name || '');
|
||||
let html = '<div class="msg">';
|
||||
html += '<div class="msg-avatar" style="color:' + color + '">' + initials(message.user_name) + '</div>';
|
||||
html += '<div class="msg-content">';
|
||||
html += '<span class="msg-author">' + esc(message.user_name) + '</span>';
|
||||
html += '<span class="msg-time">' + esc(message.time) + '</span>';
|
||||
html += '<div class="msg-text">' + esc(message.text) + '</div>';
|
||||
if (message.files && message.files.length) {
|
||||
html += '<div class="msg-files">' + message.files.map(file =>
|
||||
'<span class="msg-file">📎 ' + esc(file.name) + ' <span style="color:#94a3b8">(' + fmtBytes(file.size) + ')</span></span>'
|
||||
).join('') + '</div>';
|
||||
}
|
||||
if (message.reactions && message.reactions.length) {
|
||||
html += '<div class="msg-reactions">' + message.reactions.map(reaction =>
|
||||
'<span class="msg-reaction">:' + esc(reaction.name) + ': ' + esc(reaction.count) + '</span>'
|
||||
).join('') + '</div>';
|
||||
}
|
||||
if (message.has_thread && currentChannel) {
|
||||
html += '<div class="msg-thread" onclick="openThread(\'' + esc(currentChannel.id) + '\',\'' + esc(message.ts) + '\')">' +
|
||||
esc(message.reply_count) + ' replies</div>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
async function openThread(channelId, threadTs) {
|
||||
const data = await fetchJSON('/api/threads/' + encodeURIComponent(channelId) + '/' + encodeURIComponent(threadTs));
|
||||
const panel = document.getElementById('thread-panel');
|
||||
panel.classList.add('open');
|
||||
const el = document.getElementById('thread-messages');
|
||||
el.innerHTML = data.messages.length ? data.messages.map(message => renderMessage(message)).join('') : '<div class="empty-msg">No replies</div>';
|
||||
}
|
||||
|
||||
function closeThread() {
|
||||
document.getElementById('thread-panel').classList.remove('open');
|
||||
}
|
||||
|
||||
async function showPanel(event, name) {
|
||||
document.querySelectorAll('.channels-nav button').forEach(button => button.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
const msgPane = document.getElementById('message-pane');
|
||||
const usersPane = document.getElementById('users-panel');
|
||||
if (name === 'users') {
|
||||
msgPane.style.display = 'none';
|
||||
usersPane.classList.add('open');
|
||||
const data = await fetchJSON('/api/users');
|
||||
const el = document.getElementById('users-list');
|
||||
el.innerHTML = (data.users || []).filter(user => !user.deleted).map(user => {
|
||||
const color = avatarColor(user.display_name || user.name || '');
|
||||
return '<div class="user-row">' +
|
||||
'<div class="user-avatar" style="color:' + color + '">' + initials(user.display_name || user.name) + '</div>' +
|
||||
'<div class="user-info"><div class="user-name">' + esc(user.display_name || user.name) +
|
||||
(user.status ? ' <span style="font-weight:400;font-size:12px;color:#6b6b6b">' + esc(user.status_emoji) + ' ' + esc(user.status) + '</span>' : '') +
|
||||
'</div><div class="user-title">' + esc(user.title) + '</div></div></div>';
|
||||
}).join('');
|
||||
} else {
|
||||
msgPane.style.display = '';
|
||||
usersPane.classList.remove('open');
|
||||
}
|
||||
}
|
||||
|
||||
init().catch(error => {
|
||||
document.getElementById('messages').innerHTML = '<div class="empty-msg">' + esc(error.message) + '</div>';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Slack viewer and HTTP MCP host."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
|
||||
class ProxyTokenMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.url.path.startswith("/mcp"):
|
||||
return await call_next(request)
|
||||
token = os.environ.get("MCP_PROXY_TOKEN", "")
|
||||
if token and request.headers.get("x-proxy-token") != token:
|
||||
return Response("Forbidden: invalid proxy token", status_code=403)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _state_json() -> dict[str, Any]:
|
||||
from slack_mock.state import state_to_json
|
||||
|
||||
return state_to_json()
|
||||
|
||||
|
||||
def _topic_value(topic: Any) -> str:
|
||||
if isinstance(topic, dict):
|
||||
return str(topic.get("value") or "")
|
||||
return str(topic or "")
|
||||
|
||||
|
||||
def _user_display_name(user: dict[str, Any] | None, fallback: str | None = None) -> str:
|
||||
if user is None:
|
||||
return fallback or "Unknown"
|
||||
profile = user.get("profile", {})
|
||||
return (
|
||||
profile.get("display_name")
|
||||
or profile.get("real_name")
|
||||
or user.get("real_name")
|
||||
or user.get("name")
|
||||
or fallback
|
||||
or "Unknown"
|
||||
)
|
||||
|
||||
|
||||
def _format_ts(ts: str) -> str:
|
||||
try:
|
||||
dt = datetime.fromtimestamp(float(ts), tz=UTC)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return str(ts)
|
||||
return dt.strftime("%I:%M %p").lstrip("0")
|
||||
|
||||
|
||||
def _format_message(
|
||||
message: dict[str, Any],
|
||||
state: dict[str, Any],
|
||||
reply_count: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
user_id = message.get("user") or ""
|
||||
user = state.get("users", {}).get(user_id)
|
||||
# Trust a caller-derived count over the stored value: hand-authored seed
|
||||
# states can omit reply_count even when replies reference the parent.
|
||||
if reply_count is None:
|
||||
reply_count = message.get("reply_count") or 0
|
||||
return {
|
||||
"ts": message.get("ts", ""),
|
||||
"text": message.get("text", ""),
|
||||
"user_id": user_id,
|
||||
"user_name": _user_display_name(user, user_id),
|
||||
"thread_ts": message.get("thread_ts"),
|
||||
"reply_count": reply_count,
|
||||
"reactions": [
|
||||
{
|
||||
"name": reaction.get("name", ""),
|
||||
"count": reaction.get("count", 0),
|
||||
}
|
||||
for reaction in message.get("reactions", []) or []
|
||||
],
|
||||
"has_thread": bool(reply_count),
|
||||
"time": _format_ts(str(message.get("ts", ""))),
|
||||
# File metadata only -- the stored content_base64 is never surfaced.
|
||||
"files": [
|
||||
{
|
||||
"name": file.get("name") or file.get("title", ""),
|
||||
"mimetype": file.get("mimetype", ""),
|
||||
"size": file.get("size", 0),
|
||||
}
|
||||
for file in message.get("files", []) or []
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def api_channels(request: Request) -> JSONResponse:
|
||||
state = _state_json()
|
||||
messages = state.get("messages", {})
|
||||
channels = []
|
||||
for channel in state.get("channels", {}).values():
|
||||
channels.append(
|
||||
{
|
||||
"id": channel["id"],
|
||||
"name": channel["name"],
|
||||
"topic": _topic_value(channel.get("topic")),
|
||||
"purpose": _topic_value(channel.get("purpose")),
|
||||
"is_private": channel.get("is_private", False),
|
||||
"is_archived": channel.get("is_archived", False),
|
||||
"num_members": channel.get("num_members") or 0,
|
||||
"messageCount": len(messages.get(channel["id"], [])),
|
||||
}
|
||||
)
|
||||
channels.sort(key=lambda channel: channel.get("name", ""))
|
||||
return JSONResponse({"channels": channels})
|
||||
|
||||
|
||||
async def api_channel_messages(request: Request) -> JSONResponse:
|
||||
channel_id = request.path_params["channel_id"]
|
||||
state = _state_json()
|
||||
channel = state.get("channels", {}).get(channel_id)
|
||||
if channel is None:
|
||||
return JSONResponse({"error": "Channel not found"}, status_code=404)
|
||||
try:
|
||||
limit = int(request.query_params.get("limit", "100"))
|
||||
except ValueError:
|
||||
limit = 100
|
||||
channel_messages = state.get("messages", {}).get(channel_id, [])
|
||||
# Derive thread sizes from the messages actually present so a parent whose
|
||||
# stored reply_count is missing or stale still surfaces a thread link --
|
||||
# otherwise its replies (filtered out of the main list below) are unreachable.
|
||||
reply_counts: dict[str, int] = {}
|
||||
for message in channel_messages:
|
||||
thread_ts = message.get("thread_ts")
|
||||
if thread_ts and thread_ts != message.get("ts"):
|
||||
reply_counts[thread_ts] = reply_counts.get(thread_ts, 0) + 1
|
||||
messages = [
|
||||
message
|
||||
for message in channel_messages
|
||||
if not message.get("thread_ts") or message.get("thread_ts") == message.get("ts")
|
||||
]
|
||||
messages.sort(key=lambda message: float(message.get("ts", 0)), reverse=True)
|
||||
formatted = [
|
||||
_format_message(message, state, reply_count=reply_counts.get(message.get("ts", ""), 0)) for message in messages
|
||||
]
|
||||
return JSONResponse(
|
||||
{
|
||||
"channel": {"id": channel["id"], "name": channel["name"]},
|
||||
"messages": formatted[:limit],
|
||||
"total": len(formatted),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_thread(request: Request) -> JSONResponse:
|
||||
channel_id = request.path_params["channel_id"]
|
||||
thread_ts = request.path_params["thread_ts"]
|
||||
from slack_mock.state import get_thread_replies
|
||||
|
||||
state = _state_json()
|
||||
replies = [
|
||||
_format_message(message.model_dump(mode="json", by_alias=True, exclude_none=True), state)
|
||||
for message in get_thread_replies(channel_id, thread_ts)
|
||||
]
|
||||
if not replies:
|
||||
return JSONResponse({"error": "Thread not found"}, status_code=404)
|
||||
return JSONResponse({"messages": replies})
|
||||
|
||||
|
||||
async def api_users(request: Request) -> JSONResponse:
|
||||
state = _state_json()
|
||||
users = []
|
||||
for user in state.get("users", {}).values():
|
||||
profile = user.get("profile", {})
|
||||
users.append(
|
||||
{
|
||||
"id": user["id"],
|
||||
"name": user["name"],
|
||||
"real_name": user.get("real_name") or profile.get("real_name") or user.get("name", ""),
|
||||
"display_name": profile.get("display_name") or profile.get("real_name") or user.get("name", ""),
|
||||
"title": profile.get("title", ""),
|
||||
"email": profile.get("email", ""),
|
||||
"is_bot": user.get("is_bot", False),
|
||||
"deleted": user.get("deleted", False),
|
||||
"status": profile.get("status_text", ""),
|
||||
"status_emoji": profile.get("status_emoji", ""),
|
||||
}
|
||||
)
|
||||
return JSONResponse({"users": users})
|
||||
|
||||
|
||||
async def viewer_html(request: Request) -> HTMLResponse:
|
||||
return HTMLResponse(VIEWER_HTML)
|
||||
|
||||
|
||||
VIEWER_HTML = (Path(__file__).parent / "viewer.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def create_slack_viewer_app() -> Starlette:
|
||||
routes = [
|
||||
Route("/", viewer_html),
|
||||
Route("/api/channels", api_channels),
|
||||
Route("/api/channels/{channel_id}/messages", api_channel_messages),
|
||||
Route("/api/threads/{channel_id}/{thread_ts}", api_thread),
|
||||
Route("/api/users", api_users),
|
||||
]
|
||||
return Starlette(routes=routes, middleware=[Middleware(ProxyTokenMiddleware)])
|
||||
|
||||
|
||||
def run_http_server(mcp_app, port: int) -> None:
|
||||
fastmcp_asgi = mcp_app.http_app(transport="streamable-http", path="/mcp")
|
||||
viewer = create_slack_viewer_app()
|
||||
|
||||
async def combined_app(scope, receive, send):
|
||||
if scope["type"] == "lifespan":
|
||||
await fastmcp_asgi(scope, receive, send)
|
||||
return
|
||||
path = scope.get("path", "")
|
||||
if path.startswith("/mcp"):
|
||||
await fastmcp_asgi(scope, receive, send)
|
||||
else:
|
||||
await viewer(scope, receive, send)
|
||||
|
||||
uvicorn.run(combined_app, host="127.0.0.1", port=port, log_level="warning")
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from slack_mock.state import set_snapshot_paths
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_env(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLEDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
monkeypatch.delenv("MCP_PROXY_TOKEN", raising=False)
|
||||
set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
yield
|
||||
set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from slack_mock.state import state_from_json
|
||||
|
||||
|
||||
def seed_state(data: dict[str, Any]) -> None:
|
||||
defaults = {
|
||||
"bot_user_id": "U_MOCK_BOT",
|
||||
"users": {
|
||||
"U_MOCK_BOT": {
|
||||
"id": "U_MOCK_BOT",
|
||||
"name": "bot",
|
||||
"real_name": "Mock Bot",
|
||||
"profile": {"display_name": "bot", "status_text": "", "status_emoji": ""},
|
||||
"deleted": False,
|
||||
}
|
||||
},
|
||||
"channels": {},
|
||||
"messages": {},
|
||||
"counters": {"channelId": 1000, "fileId": 1000},
|
||||
}
|
||||
merged = {**defaults, **data}
|
||||
merged["users"] = {**defaults["users"], **data.get("users", {})}
|
||||
merged["counters"] = {**defaults["counters"], **data.get("counters", {})}
|
||||
state_from_json(merged)
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from helpers import seed_state
|
||||
|
||||
from slack_mock.server import archive_channel, create_channel, list_channels, rename_channel, set_channel_topic
|
||||
from slack_mock.state import get_state
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
seed_state(
|
||||
{
|
||||
"users": {"U001": {"id": "U001", "name": "alice"}},
|
||||
"channels": {
|
||||
"C001": {
|
||||
"id": "C001",
|
||||
"name": "general",
|
||||
"name_normalized": "general",
|
||||
"is_general": True,
|
||||
"context_team_id": "T_MOCK",
|
||||
"updated": 1700000000,
|
||||
"creator": "U001",
|
||||
"num_members": 5,
|
||||
"previous_names": [],
|
||||
"topic": {"value": "General chat", "creator": "U001", "last_set": 1700000000},
|
||||
"purpose": {"value": "Team comms", "creator": "U001", "last_set": 1700000000},
|
||||
},
|
||||
"C002": {
|
||||
"id": "C002",
|
||||
"name": "random",
|
||||
"name_normalized": "random",
|
||||
"is_archived": True,
|
||||
"context_team_id": "T_MOCK",
|
||||
"updated": 1700000000,
|
||||
"creator": "U001",
|
||||
"num_members": 3,
|
||||
"previous_names": [],
|
||||
"topic": {"value": "", "creator": "", "last_set": 0},
|
||||
"purpose": {"value": "", "creator": "", "last_set": 0},
|
||||
},
|
||||
"C003": {
|
||||
"id": "C003",
|
||||
"name": "external",
|
||||
"context_team_id": "T_MOCK",
|
||||
"is_member": False,
|
||||
},
|
||||
"D001": {
|
||||
"id": "D001",
|
||||
"name": "alice",
|
||||
"context_team_id": "T_MOCK",
|
||||
"is_channel": False,
|
||||
"is_im": True,
|
||||
"is_private": True,
|
||||
"user": "U001",
|
||||
},
|
||||
"G001": {
|
||||
"id": "G001",
|
||||
"name": "private",
|
||||
"context_team_id": "T_MOCK",
|
||||
"is_channel": False,
|
||||
"is_group": True,
|
||||
"is_private": True,
|
||||
},
|
||||
},
|
||||
"messages": {"C001": [], "C002": [], "C003": [], "D001": [], "G001": []},
|
||||
"counters": {"channelId": 1000},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_channels_filters_to_active_channel_memberships() -> None:
|
||||
listed = await list_channels()
|
||||
|
||||
assert listed["ok"] is True
|
||||
assert [channel["id"] for channel in listed["channels"]] == ["C001", "G001"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_channel_public_private_and_messages() -> None:
|
||||
public = await create_channel("engineering")
|
||||
private = await create_channel("secret", is_private=True)
|
||||
|
||||
assert public["ok"] is True
|
||||
assert public["channel"]["name"] == "engineering"
|
||||
assert public["channel"]["is_private"] is False
|
||||
assert public["channel"]["is_channel"] is True
|
||||
assert get_state().messages[public["channel"]["id"]] == []
|
||||
|
||||
assert private["ok"] is True
|
||||
assert private["channel"]["is_private"] is True
|
||||
assert private["channel"]["is_group"] is True
|
||||
assert private["channel"]["is_channel"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_channel_normalizes_and_rejects_bad_names() -> None:
|
||||
normalized = await create_channel("MyChannel")
|
||||
duplicate = await create_channel("general")
|
||||
empty = await create_channel(" ")
|
||||
second = await create_channel("chan-b")
|
||||
|
||||
assert normalized["channel"]["name"] == "mychannel"
|
||||
assert normalized["channel"]["name_normalized"] == "mychannel"
|
||||
assert duplicate["ok"] is False
|
||||
assert duplicate["error"] == "name_taken"
|
||||
assert empty["ok"] is False
|
||||
assert empty["error"] == "invalid_name"
|
||||
assert normalized["channel"]["id"] != second["channel"]["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_channel() -> None:
|
||||
before = get_state().channels["C001"].updated
|
||||
archived = await archive_channel("C001")
|
||||
already = await archive_channel("C002")
|
||||
missing = await archive_channel("INVALID")
|
||||
|
||||
assert archived["ok"] is True
|
||||
assert get_state().channels["C001"].is_archived is True
|
||||
assert get_state().channels["C001"].updated >= before
|
||||
assert already == {"ok": False, "error": "already_archived"}
|
||||
assert missing == {"ok": False, "error": "channel_not_found"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_channel() -> None:
|
||||
renamed = await rename_channel("C001", "announcements")
|
||||
normalized = await rename_channel("C001", "NewName")
|
||||
duplicate = await rename_channel("C001", "random")
|
||||
missing = await rename_channel("INVALID", "test")
|
||||
empty = await rename_channel("C001", " ")
|
||||
|
||||
assert renamed["ok"] is True
|
||||
assert renamed["channel"]["name"] == "announcements"
|
||||
previous_names = get_state().channels["C001"].previous_names
|
||||
assert previous_names is not None
|
||||
assert "general" in previous_names
|
||||
assert normalized["channel"]["name"] == "newname"
|
||||
assert duplicate["error"] == "name_taken"
|
||||
assert missing["error"] == "channel_not_found"
|
||||
assert empty["error"] == "invalid_name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_channel_topic_and_purpose() -> None:
|
||||
topic = await set_channel_topic("C001", topic="New topic")
|
||||
assert topic["ok"] is True
|
||||
assert topic["channel"]["topic"]["value"] == "New topic"
|
||||
assert topic["channel"]["topic"]["creator"] == "U_MOCK_BOT"
|
||||
assert topic["channel"]["purpose"]["value"] == "Team comms"
|
||||
|
||||
purpose = await set_channel_topic("C001", purpose="New purpose")
|
||||
assert purpose["channel"]["purpose"]["value"] == "New purpose"
|
||||
|
||||
both = await set_channel_topic("C001", topic="T", purpose="P")
|
||||
assert both["channel"]["topic"]["value"] == "T"
|
||||
assert both["channel"]["purpose"]["value"] == "P"
|
||||
assert (await set_channel_topic("INVALID", topic="test"))["error"] == "channel_not_found"
|
||||
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from helpers import seed_state
|
||||
|
||||
from slack_mock.models import SlackChannel
|
||||
from slack_mock.server import (
|
||||
archive_channel,
|
||||
create_channel,
|
||||
list_dms,
|
||||
open_dm,
|
||||
open_mpim,
|
||||
rename_channel,
|
||||
send_dm,
|
||||
send_mpim,
|
||||
)
|
||||
from slack_mock.state import get_state
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
seed_state(
|
||||
{
|
||||
"channels": {"C001": {"id": "C001", "name": "general", "is_im": False, "context_team_id": "T_MOCK"}},
|
||||
"messages": {"C001": []},
|
||||
"users": {
|
||||
"U001": {
|
||||
"id": "U001",
|
||||
"name": "alice",
|
||||
"real_name": "Alice Smith",
|
||||
"profile": {"display_name": "alice"},
|
||||
},
|
||||
"U002": {"id": "U002", "name": "bob", "real_name": "Bob Jones", "profile": {"display_name": "bob"}},
|
||||
"U003": {
|
||||
"id": "U003",
|
||||
"name": "charlie",
|
||||
"real_name": "Charlie Day",
|
||||
"profile": {"display_name": "charlie"},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_dm() -> None:
|
||||
result = await open_dm("U001")
|
||||
assert result["ok"] is True
|
||||
assert result["channel"]["is_im"] is True
|
||||
assert result["channel"]["user"] == "U001"
|
||||
assert result["channel"]["is_private"] is True
|
||||
assert result["channel"]["is_channel"] is False
|
||||
assert get_state().messages[result["channel"]["id"]] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_dm_reuses_existing_and_separates_users() -> None:
|
||||
first = await open_dm("U001")
|
||||
second = await open_dm("U001")
|
||||
other = await open_dm("U002")
|
||||
|
||||
assert second["channel"]["id"] == first["channel"]["id"]
|
||||
assert other["channel"]["id"] != first["channel"]["id"]
|
||||
assert (await open_dm("NONEXISTENT"))["error"] == "user_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dms() -> None:
|
||||
assert (await list_dms())["channels"] == []
|
||||
await open_dm("U001")
|
||||
await open_dm("U002")
|
||||
result = await list_dms()
|
||||
assert len(result["channels"]) == 2
|
||||
assert all(channel["is_im"] for channel in result["channels"])
|
||||
assert all(channel["id"] != "C001" for channel in result["channels"])
|
||||
assert len((await list_dms(limit=1))["channels"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_conversations_cannot_be_archived_or_renamed() -> None:
|
||||
dm_id = (await open_dm("U001"))["channel"]["id"]
|
||||
state = get_state()
|
||||
state.channels["GMP001"] = SlackChannel.model_validate(
|
||||
{
|
||||
"id": "GMP001",
|
||||
"name": "mpdm-alice--bob-1",
|
||||
"is_channel": False,
|
||||
"is_group": False,
|
||||
"is_im": False,
|
||||
"is_mpim": True,
|
||||
"is_private": True,
|
||||
"context_team_id": "T_MOCK",
|
||||
}
|
||||
)
|
||||
state.messages["GMP001"] = []
|
||||
|
||||
for channel_id in (dm_id, "GMP001"):
|
||||
archived = await archive_channel(channel_id)
|
||||
renamed = await rename_channel(channel_id, "new-name")
|
||||
|
||||
assert archived == {"ok": False, "error": "method_not_supported_for_channel_type"}
|
||||
assert renamed == {"ok": False, "error": "method_not_supported_for_channel_type", "channel": {}}
|
||||
|
||||
assert state.channels[dm_id].is_archived is False
|
||||
assert state.channels[dm_id].name == "alice"
|
||||
assert state.channels["GMP001"].is_archived is False
|
||||
assert state.channels["GMP001"].name == "mpdm-alice--bob-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dms_includes_multi_party_direct_conversations() -> None:
|
||||
state = get_state()
|
||||
state.channels["GMP001"] = SlackChannel.model_validate(
|
||||
{
|
||||
"id": "GMP001",
|
||||
"name": "mpdm-alice--bob-1",
|
||||
"is_channel": False,
|
||||
"is_group": False,
|
||||
"is_im": False,
|
||||
"is_mpim": True,
|
||||
"is_private": True,
|
||||
"context_team_id": "T_MOCK",
|
||||
}
|
||||
)
|
||||
state.messages["GMP001"] = []
|
||||
|
||||
result = await list_dms()
|
||||
|
||||
assert [channel["id"] for channel in result["channels"]] == ["GMP001"]
|
||||
assert result["channels"][0]["is_mpim"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_mpim_reuses_existing_conversation_by_members() -> None:
|
||||
first = await open_mpim(["U002", "U001"])
|
||||
second = await open_mpim(["U001", "U002"])
|
||||
|
||||
assert first["ok"] is True
|
||||
assert first["channel"]["is_mpim"] is True
|
||||
assert first["channel"]["is_private"] is True
|
||||
assert first["channel"]["members"] == ["U_MOCK_BOT", "U001", "U002"]
|
||||
assert second["channel"]["id"] == first["channel"]["id"]
|
||||
assert [channel["id"] for channel in (await list_dms())["channels"]] == [first["channel"]["id"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_mpim_validates_user_list() -> None:
|
||||
assert await open_mpim(["U001"]) == {"ok": False, "error": "not_enough_users", "channel": {}}
|
||||
assert await open_mpim(["U001", "U001"]) == {"ok": False, "error": "not_enough_users", "channel": {}}
|
||||
assert await open_mpim(["U001", "U404"]) == {"ok": False, "error": "user_not_found", "channel": {}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_mpim_posts_and_reuses_conversation() -> None:
|
||||
sent = await send_mpim(["U001", "U002"], "Hello group")
|
||||
second = await send_mpim(["U002", "U001"], "Second group note")
|
||||
|
||||
assert sent["ok"] is True
|
||||
assert sent["message"]["text"] == "Hello group"
|
||||
assert second["channel"] == sent["channel"]
|
||||
assert [message.text for message in get_state().messages[sent["channel"]]] == ["Hello group", "Second group note"]
|
||||
assert (await send_mpim(["U001", "U404"], "Hi"))["ok"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_names_do_not_reserve_channel_names() -> None:
|
||||
opened = await open_dm("U001")
|
||||
|
||||
created = await create_channel(opened["channel"]["name"])
|
||||
|
||||
assert created["ok"] is True
|
||||
assert created["channel"]["name"] == "alice"
|
||||
assert created["channel"]["is_im"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm() -> None:
|
||||
result = await send_dm("U001", "Hello Alice!")
|
||||
assert result["ok"] is True
|
||||
assert result["message"]["text"] == "Hello Alice!"
|
||||
assert result["channel"]
|
||||
assert result["ts"]
|
||||
|
||||
assert len((await list_dms())["channels"]) == 1
|
||||
await send_dm("U001", "Second")
|
||||
assert len((await list_dms())["channels"]) == 1
|
||||
assert get_state().messages[result["channel"]][0].text == "Hello Alice!"
|
||||
assert (await send_dm("NONEXISTENT", "Hi"))["ok"] is False
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from helpers import seed_state
|
||||
|
||||
from slack_mock.server import list_files, upload_file
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
seed_state(
|
||||
{
|
||||
"channels": {"C001": {"id": "C001", "name": "general", "context_team_id": "T_MOCK"}},
|
||||
"messages": {"C001": []},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_file_creates_message_and_detects_type() -> None:
|
||||
result = await upload_file("C001", "report.pdf", "AAAA")
|
||||
assert result["ok"] is True
|
||||
assert result["file"]["name"] == "report.pdf"
|
||||
assert result["file"]["mimetype"] == "application/pdf"
|
||||
assert result["file"]["id"]
|
||||
|
||||
png = await upload_file("C001", "screenshot.png", "AA==")
|
||||
csv = await upload_file("C001", "data.csv", "AA==")
|
||||
assert png["file"]["mimetype"] == "image/png"
|
||||
assert png["file"]["filetype"] == "png"
|
||||
assert csv["file"]["mimetype"] == "text/csv"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_file_title_comment_errors_and_size() -> None:
|
||||
titled = await upload_file("C001", "q1.xlsx", "AA==", title="Q1 Financial Report")
|
||||
assert titled["file"]["title"] == "Q1 Financial Report"
|
||||
|
||||
await upload_file("C001", "doc.pdf", "AA==", initial_comment="Here is the spec document")
|
||||
files = await list_files("C001")
|
||||
assert files["total"] == 2
|
||||
|
||||
missing = await upload_file("INVALID", "x.txt", "AA==")
|
||||
assert missing["ok"] is False
|
||||
assert missing["error"] == "channel_not_found"
|
||||
|
||||
sized = await upload_file("C001", "test.txt", "SGVsbG8gV29ybGQ=")
|
||||
assert sized["file"]["size"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_files_empty_uploaded_limit_and_errors() -> None:
|
||||
assert await list_files("C001") == {"ok": True, "files": [], "total": 0}
|
||||
await upload_file("C001", "a.png", "AA==")
|
||||
await upload_file("C001", "b.pdf", "AA==")
|
||||
result = await list_files("C001")
|
||||
assert result["total"] == 2
|
||||
assert {file["name"] for file in result["files"]} == {"a.png", "b.pdf"}
|
||||
|
||||
await upload_file("C001", "c.png", "AA==")
|
||||
limited = await list_files("C001", limit=2)
|
||||
assert len(limited["files"]) == 2
|
||||
assert limited["total"] == 3
|
||||
assert (await list_files("INVALID"))["error"] == "channel_not_found"
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from helpers import seed_state
|
||||
|
||||
from slack_mock.server import delete_message, edit_message
|
||||
from slack_mock.state import get_state
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
seed_state(
|
||||
{
|
||||
"is_admin": True,
|
||||
"users": {
|
||||
"U001": {"id": "U001", "name": "alice"},
|
||||
"U002": {"id": "U002", "name": "bob"},
|
||||
},
|
||||
"channels": {"C001": {"id": "C001", "name": "general", "context_team_id": "T_MOCK"}},
|
||||
"messages": {
|
||||
"C001": [
|
||||
{"ts": "1700000001.000", "text": "Hello world", "user": "U001", "type": "message"},
|
||||
{
|
||||
"ts": "1700000002.000",
|
||||
"text": "Reply in thread",
|
||||
"user": "U002",
|
||||
"type": "message",
|
||||
"thread_ts": "1700000001.000",
|
||||
},
|
||||
{"ts": "1700000003.000", "text": "Another message", "user": "U001", "type": "message"},
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message() -> None:
|
||||
result = await edit_message("C001", "1700000001.000", "Updated text")
|
||||
assert result["ok"] is True
|
||||
assert result["text"] == "Updated text"
|
||||
assert result["message"]["text"] == "Updated text"
|
||||
assert result["channel"] == "C001"
|
||||
assert result["ts"] == "1700000001.000"
|
||||
|
||||
msg = get_state().messages["C001"][0]
|
||||
assert msg.edited is not None
|
||||
assert msg.edited.user == "U_MOCK_BOT"
|
||||
assert msg.edited.ts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_errors_and_thread_replies() -> None:
|
||||
assert (await edit_message("INVALID", "1700000001.000", "x"))["error"] == "channel_not_found"
|
||||
assert (await edit_message("C001", "9999999999.000", "x"))["error"] == "message_not_found"
|
||||
|
||||
reply = await edit_message("C001", "1700000002.000", "Edited reply")
|
||||
assert reply["ok"] is True
|
||||
assert reply["message"]["text"] == "Edited reply"
|
||||
assert reply["message"]["thread_ts"] == "1700000001.000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_message() -> None:
|
||||
assert len(get_state().messages["C001"]) == 3
|
||||
result = await delete_message("C001", "1700000003.000")
|
||||
assert result == {"ok": True, "channel": "C001", "ts": "1700000003.000"}
|
||||
assert len(get_state().messages["C001"]) == 2
|
||||
assert all(message.ts != "1700000003.000" for message in get_state().messages["C001"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_message_errors_and_thread_replies() -> None:
|
||||
assert (await delete_message("INVALID", "1700000001.000"))["error"] == "channel_not_found"
|
||||
assert (await delete_message("C001", "9999999999.000"))["error"] == "message_not_found"
|
||||
|
||||
result = await delete_message("C001", "1700000002.000")
|
||||
assert result["ok"] is True
|
||||
assert any(message.ts == "1700000001.000" for message in get_state().messages["C001"])
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from slack_mock.server import get_channel_history, list_workspaces, post_message, search_messages
|
||||
from slack_mock.state import get_active_workspace_id, state_from_json, state_to_json
|
||||
|
||||
|
||||
def _workspace_state(message_text: str) -> dict:
|
||||
return {
|
||||
"bot_user_id": "U_MOCK_BOT",
|
||||
"users": {
|
||||
"U_MOCK_BOT": {
|
||||
"id": "U_MOCK_BOT",
|
||||
"name": "bot",
|
||||
"real_name": "Mock Bot",
|
||||
"profile": {"display_name": "bot", "status_text": "", "status_emoji": ""},
|
||||
"deleted": False,
|
||||
}
|
||||
},
|
||||
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
|
||||
"messages": {"C1": [{"ts": "1700000001.000", "text": message_text, "user": "U_MOCK_BOT", "type": "message"}]},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_selector_routes_reads_and_writes_independently() -> None:
|
||||
state_from_json(
|
||||
{
|
||||
"workspaces": {
|
||||
"default": _workspace_state("default workspace"),
|
||||
"acme": _workspace_state("acme workspace"),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
listed = await list_workspaces()
|
||||
assert listed["ok"] is True
|
||||
assert listed["total"] == 2
|
||||
assert {workspace["workspace_id"] for workspace in listed["workspaces"]} == {"default", "acme"}
|
||||
|
||||
default_history = await get_channel_history("C1")
|
||||
acme_history = await get_channel_history("C1", workspace_id="acme")
|
||||
assert default_history["messages"][0]["text"] == "default workspace"
|
||||
assert acme_history["messages"][0]["text"] == "acme workspace"
|
||||
|
||||
posted = await post_message("C1", "acme follow-up", workspace_id="acme")
|
||||
assert posted["ok"] is True
|
||||
|
||||
exported = state_to_json()
|
||||
assert len(exported["workspaces"]["default"]["messages"]["C1"]) == 1
|
||||
assert len(exported["workspaces"]["acme"]["messages"]["C1"]) == 2
|
||||
|
||||
assert (await search_messages("acme", workspace_id="acme"))["messages"]["total"] == 2
|
||||
assert (await search_messages("acme", workspace_id="default"))["messages"]["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_workspace_write_does_not_mutate_other_workspaces() -> None:
|
||||
state_from_json(
|
||||
{
|
||||
"workspaces": {
|
||||
"default": _workspace_state("default workspace"),
|
||||
"acme": _workspace_state("acme workspace"),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
before = state_to_json()
|
||||
failed = await post_message("C_DOES_NOT_EXIST", "should not post", workspace_id="acme")
|
||||
|
||||
assert failed["ok"] is False
|
||||
assert failed["error"] == "channel_not_found"
|
||||
assert state_to_json() == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_import_resets_active_workspace() -> None:
|
||||
state_from_json(
|
||||
{
|
||||
"workspaces": {
|
||||
"default": _workspace_state("default workspace"),
|
||||
"acme": _workspace_state("acme workspace"),
|
||||
}
|
||||
}
|
||||
)
|
||||
await get_channel_history("C1", workspace_id="acme")
|
||||
assert get_active_workspace_id() == "acme"
|
||||
|
||||
state_from_json({"workspaces": {"default": _workspace_state("reset default workspace")}})
|
||||
|
||||
assert get_active_workspace_id() == "default"
|
||||
assert (await get_channel_history("C1"))["messages"][0]["text"] == "reset default workspace"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_omitted_workspace_uses_active_non_default_workspace() -> None:
|
||||
state_from_json({"workspaces": {"acme": _workspace_state("acme only workspace")}})
|
||||
|
||||
assert get_active_workspace_id() == "acme"
|
||||
assert (await get_channel_history("C1"))["messages"][0]["text"] == "acme only workspace"
|
||||
|
||||
posted = await post_message("C1", "acme follow-up")
|
||||
|
||||
assert posted["ok"] is True
|
||||
assert state_to_json()["workspaces"]["acme"]["messages"]["C1"][-1]["text"] == "acme follow-up"
|
||||
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from helpers import seed_state
|
||||
|
||||
from slack_mock.server import (
|
||||
delete_message,
|
||||
edit_message,
|
||||
get_user_presence,
|
||||
list_pins,
|
||||
pin_message,
|
||||
set_user_status,
|
||||
unpin_message,
|
||||
)
|
||||
from slack_mock.state import get_state
|
||||
|
||||
|
||||
def seed_pins_state() -> None:
|
||||
seed_state(
|
||||
{
|
||||
"channels": {"C001": {"id": "C001", "name": "general", "context_team_id": "T_MOCK"}},
|
||||
"messages": {
|
||||
"C001": [
|
||||
{"ts": "1700000001.000", "text": "Hello world", "user": "U001", "type": "message"},
|
||||
{
|
||||
"ts": "1700000002.000",
|
||||
"text": "Pinned already",
|
||||
"user": "U001",
|
||||
"type": "message",
|
||||
"pinned_to": ["C001"],
|
||||
"pinned_info": {"C001": {"pinned_by": "U001", "pinned_ts": 1700000000}},
|
||||
},
|
||||
{"ts": "1700000003.000", "text": "Another message", "user": "U002", "type": "message"},
|
||||
]
|
||||
},
|
||||
"users": {
|
||||
"U001": {
|
||||
"id": "U001",
|
||||
"name": "alice",
|
||||
"deleted": False,
|
||||
"profile": {"display_name": "alice", "status_text": "", "status_emoji": ""},
|
||||
},
|
||||
"U002": {
|
||||
"id": "U002",
|
||||
"name": "bob",
|
||||
"deleted": True,
|
||||
"profile": {"display_name": "bob", "status_text": "", "status_emoji": ""},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_unpin_and_list_pins() -> None:
|
||||
seed_pins_state()
|
||||
pinned = await pin_message("C001", "1700000001.000")
|
||||
assert pinned["ok"] is True
|
||||
msg = get_state().messages["C001"][0]
|
||||
assert msg.pinned_to is not None
|
||||
assert msg.pinned_info is not None
|
||||
assert "C001" in msg.pinned_to
|
||||
assert msg.pinned_info["C001"]["pinned_by"] == "U_MOCK_BOT"
|
||||
|
||||
assert (await pin_message("C001", "1700000002.000"))["error"] == "already_pinned"
|
||||
assert (await pin_message("INVALID", "1700000001.000"))["error"] == "channel_not_found"
|
||||
assert (await pin_message("C001", "9999999999.000"))["error"] == "message_not_found"
|
||||
|
||||
listed = await list_pins("C001")
|
||||
assert listed["ok"] is True
|
||||
assert len(listed["items"]) == 2
|
||||
|
||||
unpinned = await unpin_message("C001", "1700000002.000")
|
||||
assert unpinned["ok"] is True
|
||||
assert get_state().messages["C001"][1].pinned_to is None
|
||||
assert (await unpin_message("C001", "1700000001.000"))["ok"] is True
|
||||
assert (await unpin_message("C001", "1700000001.000"))["error"] == "not_pinned"
|
||||
assert (await unpin_message("INVALID", "1700000002.000"))["error"] == "channel_not_found"
|
||||
assert (await list_pins("INVALID"))["error"] == "channel_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_and_presence() -> None:
|
||||
seed_pins_state()
|
||||
default = await set_user_status("In a meeting")
|
||||
assert default["ok"] is True
|
||||
assert default["profile"]["status_text"] == "In a meeting"
|
||||
assert get_state().users["U_MOCK_BOT"].profile.status_text == "In a meeting"
|
||||
|
||||
emoji = await set_user_status("Vacation", status_emoji=":palm_tree:")
|
||||
assert emoji["profile"]["status_emoji"] == ":palm_tree:"
|
||||
denied = await set_user_status("Busy", user_id="U001")
|
||||
assert denied["ok"] is False
|
||||
assert denied["error"] == "cant_update_profile"
|
||||
assert get_state().users["U001"].profile.status_text == ""
|
||||
assert (await set_user_status("test", user_id="INVALID"))["error"] == "user_not_found"
|
||||
assert (await set_user_status(""))["profile"]["status_text"] == ""
|
||||
|
||||
active = await get_user_presence("U001")
|
||||
away = await get_user_presence("U002")
|
||||
missing = await get_user_presence("INVALID")
|
||||
assert active["presence"] == "active"
|
||||
assert active["online"] is True
|
||||
assert active["manual_away"] is False
|
||||
assert away["presence"] == "away"
|
||||
assert away["online"] is False
|
||||
assert away["manual_away"] is True
|
||||
assert missing["error"] == "user_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_set_other_user_status() -> None:
|
||||
seed_pins_state()
|
||||
get_state().is_admin = True
|
||||
|
||||
result = await set_user_status("Busy", user_id="U001")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert get_state().users["U001"].profile.status_text == "Busy"
|
||||
|
||||
|
||||
def seed_permissions_state(is_admin: bool = False) -> None:
|
||||
seed_state(
|
||||
{
|
||||
"is_admin": is_admin,
|
||||
"channels": {"C001": {"id": "C001", "name": "general"}},
|
||||
"messages": {
|
||||
"C001": [
|
||||
{"ts": "1700000001.000", "text": "Bot message", "user": "U_MOCK_BOT", "type": "message"},
|
||||
{"ts": "1700000002.000", "text": "Someone else wrote this", "user": "U_OTHER", "type": "message"},
|
||||
]
|
||||
},
|
||||
"users": {"U_OTHER": {"id": "U_OTHER", "name": "other"}},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_delete_permissions() -> None:
|
||||
seed_permissions_state(is_admin=False)
|
||||
own_edit = await edit_message("C001", "1700000001.000", "Updated")
|
||||
other_edit = await edit_message("C001", "1700000002.000", "Altered")
|
||||
assert own_edit["ok"] is True
|
||||
assert get_state().messages["C001"][0].text == "Updated"
|
||||
assert other_edit["ok"] is False
|
||||
assert other_edit["error"] == "cant_update_message"
|
||||
assert get_state().messages["C001"][1].text == "Someone else wrote this"
|
||||
|
||||
own_delete = await delete_message("C001", "1700000001.000")
|
||||
assert own_delete["ok"] is True
|
||||
assert len(get_state().messages["C001"]) == 1
|
||||
|
||||
seed_permissions_state(is_admin=False)
|
||||
other_delete = await delete_message("C001", "1700000002.000")
|
||||
assert other_delete["ok"] is False
|
||||
assert other_delete["error"] == "cant_delete_message"
|
||||
assert len(get_state().messages["C001"]) == 2
|
||||
|
||||
seed_permissions_state(is_admin=True)
|
||||
assert (await edit_message("C001", "1700000002.000", "Altered"))["ok"] is True
|
||||
assert (await delete_message("C001", "1700000002.000"))["ok"] is True
|
||||
assert len(get_state().messages["C001"]) == 1
|
||||
@@ -0,0 +1,365 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from slack_mock.models import SlackState
|
||||
from slack_mock.server import (
|
||||
create_channel,
|
||||
delete_message,
|
||||
open_dm,
|
||||
post_message,
|
||||
reply_to_thread,
|
||||
search_messages,
|
||||
send_dm,
|
||||
set_user_status,
|
||||
)
|
||||
from slack_mock.state import get_bot_user_id, get_state, mutate_state, reset_state, state_from_json
|
||||
|
||||
|
||||
def _seed_state() -> None:
|
||||
state_from_json(
|
||||
{
|
||||
"bot_user_id": "U_MOCK_BOT",
|
||||
"users": {
|
||||
"U_MOCK_BOT": {
|
||||
"id": "U_MOCK_BOT",
|
||||
"name": "slackbot",
|
||||
"profile": {"display_name": "Mock Bot"},
|
||||
"is_bot": True,
|
||||
},
|
||||
"U001": {
|
||||
"id": "U001",
|
||||
"name": "alice",
|
||||
"real_name": "Alice Example",
|
||||
"profile": {"display_name": "Alice", "email": "alice@example.com"},
|
||||
},
|
||||
},
|
||||
"channels": {
|
||||
"C001": {
|
||||
"id": "C001",
|
||||
"name": "general",
|
||||
"context_team_id": "T_MOCK",
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"C001": [
|
||||
{
|
||||
"type": "message",
|
||||
"user": "U001",
|
||||
"text": "March hours are ready",
|
||||
"ts": "1700000001.000",
|
||||
"team": "T_MOCK",
|
||||
"reactions": [{"name": "eyes", "users": ["U_MOCK_BOT"], "count": 1}],
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"user": "U_MOCK_BOT",
|
||||
"text": "Budget link https://example.com",
|
||||
"ts": "1700000002.000",
|
||||
"team": "T_MOCK",
|
||||
},
|
||||
]
|
||||
},
|
||||
"counters": {"channelId": 1000, "fileId": 1000},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def seeded_state() -> None:
|
||||
_seed_state()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_channel_preserves_existing_name_normalization_behavior() -> None:
|
||||
result = await create_channel("MyChannel")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["channel"]["name"] == "mychannel"
|
||||
assert result["channel"]["name_normalized"] == "mychannel"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_messages_keeps_warning_based_limit_and_cursor_behavior() -> None:
|
||||
result = await search_messages("budget", limit=999, cursor="not-a-number")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["messages"]["total"] == 1
|
||||
assert result["response_metadata"]["warnings"] == [
|
||||
"limit exceeds the maximum of 100; using 100.",
|
||||
"Invalid cursor 'not-a-number'; using the first page.",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_messages_keeps_empty_query_tool_error_shape() -> None:
|
||||
result = await search_messages("")
|
||||
|
||||
assert result == {
|
||||
"ok": False,
|
||||
"error": "missing_query",
|
||||
"messages": {"matches": [], "total": 0},
|
||||
"response_metadata": {"warnings": []},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_text_can_be_empty_to_clear_status() -> None:
|
||||
result = await set_user_status("")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["profile"]["status_text"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_and_send_dm_preserve_current_behavior() -> None:
|
||||
opened = await open_dm("U001")
|
||||
sent = await send_dm("U001", "hello")
|
||||
|
||||
assert opened["ok"] is True
|
||||
assert opened["channel"]["is_im"] is True
|
||||
assert sent["ok"] is True
|
||||
assert sent["channel"] == opened["channel"]["id"]
|
||||
assert get_state().messages[sent["channel"]][-1].text == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_state_can_write_bot_messages() -> None:
|
||||
reset_state()
|
||||
created = await create_channel("general")
|
||||
assert created["ok"] is True
|
||||
|
||||
posted = await post_message(created["channel"]["id"], "hello from default state")
|
||||
|
||||
assert posted["ok"] is True
|
||||
assert posted["message"]["user"] == "U_MOCK_BOT"
|
||||
assert "U_MOCK_BOT" in get_state().users
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_reply_metadata_updates_atomically() -> None:
|
||||
first = await reply_to_thread("C001", "1700000001.000", "first reply")
|
||||
second = await reply_to_thread("C001", "1700000001.000", "second reply")
|
||||
|
||||
assert first["ok"] is True
|
||||
assert second["ok"] is True
|
||||
parent = get_state().messages["C001"][0]
|
||||
assert parent.reply_count == 2
|
||||
assert parent.reply_users == ["U_MOCK_BOT"]
|
||||
assert parent.reply_users_count == 1
|
||||
assert parent.latest_reply == max(first["ts"], second["ts"], key=float)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleting_thread_reply_refreshes_parent_metadata() -> None:
|
||||
first = await reply_to_thread("C001", "1700000001.000", "first reply")
|
||||
second = await reply_to_thread("C001", "1700000001.000", "second reply")
|
||||
|
||||
deleted = await delete_message("C001", second["ts"])
|
||||
|
||||
assert deleted["ok"] is True
|
||||
parent = get_state().messages["C001"][0]
|
||||
assert parent.reply_count == 1
|
||||
assert parent.reply_users == ["U_MOCK_BOT"]
|
||||
assert parent.reply_users_count == 1
|
||||
assert parent.latest_reply == first["ts"]
|
||||
|
||||
|
||||
def test_failed_state_mutation_rolls_back_live_state() -> None:
|
||||
before = get_state().model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
|
||||
with pytest.raises(ValidationError, match="bot_user_id"):
|
||||
mutate_state(lambda state: state.users.pop("U_MOCK_BOT"))
|
||||
|
||||
assert get_state().model_dump(mode="json", by_alias=True, exclude_none=True) == before
|
||||
|
||||
|
||||
def test_bot_user_id_reads_current_state_after_direct_mutation() -> None:
|
||||
mutate_state(lambda state: setattr(state, "bot_user_id", "U001"))
|
||||
|
||||
assert get_bot_user_id() == "U001"
|
||||
|
||||
|
||||
def test_state_rejects_message_channel_without_matching_channel() -> None:
|
||||
with pytest.raises(ValidationError, match="does not reference an existing channel"):
|
||||
SlackState.model_validate(
|
||||
{
|
||||
"channels": {},
|
||||
"messages": {"C404": [{"type": "message", "text": "orphan", "ts": "1700000001.000"}]},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_state_rejects_reaction_count_drift() -> None:
|
||||
with pytest.raises(ValidationError, match="reaction.count"):
|
||||
SlackState.model_validate(
|
||||
{
|
||||
"channels": {"C001": {"id": "C001", "name": "general"}},
|
||||
"messages": {
|
||||
"C001": [
|
||||
{
|
||||
"type": "message",
|
||||
"text": "bad reaction",
|
||||
"ts": "1700000001.000",
|
||||
"reactions": [{"name": "eyes", "users": ["U001"], "count": 2}],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_state_rejects_unknown_extra_fields() -> None:
|
||||
with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
|
||||
SlackState.model_validate({"channels": {}, "messages": {}, "users": {}, "unexpected": True})
|
||||
|
||||
|
||||
def test_state_rejects_duplicate_usernames_but_allows_duplicate_display_names() -> None:
|
||||
state = {
|
||||
"users": {
|
||||
"U001": {"id": "U001", "name": "alice", "profile": {"display_name": "Alex"}},
|
||||
"U002": {"id": "U002", "name": "bob", "profile": {"display_name": "Alex"}},
|
||||
}
|
||||
}
|
||||
assert SlackState.model_validate(state).users["U002"].profile.display_name == "Alex"
|
||||
|
||||
state["users"]["U002"]["name"] = "Alice"
|
||||
with pytest.raises(ValidationError, match="user name"):
|
||||
SlackState.model_validate(state)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value", "match"),
|
||||
[
|
||||
("user_id", "bad-user", "String should match pattern"),
|
||||
("channel_id", "X001", "String should match pattern"),
|
||||
("file_id", "BAD001", "String should match pattern"),
|
||||
("timestamp", "1700000001", "String should match pattern"),
|
||||
("status_emoji", "palm_tree", "String should match pattern"),
|
||||
("message_type", "event", "Input should be"),
|
||||
("message_subtype", "unknown_subtype", "Input should be"),
|
||||
("file_mode", "mystery", "Input should be"),
|
||||
],
|
||||
)
|
||||
def test_state_rejects_bad_primitive_shapes(field: str, value: str, match: str) -> None:
|
||||
state: dict[str, Any] = {
|
||||
"users": {
|
||||
"U001": {
|
||||
"id": "U001",
|
||||
"name": "alice",
|
||||
"profile": {"status_emoji": ":palm_tree:"},
|
||||
}
|
||||
},
|
||||
"channels": {"C001": {"id": "C001", "name": "general"}},
|
||||
"messages": {
|
||||
"C001": [
|
||||
{
|
||||
"type": "message",
|
||||
"text": "hello",
|
||||
"ts": "1700000001.000",
|
||||
"user": "U001",
|
||||
"files": [{"id": "F001", "mode": "hosted"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
if field == "user_id":
|
||||
state["users"] = {value: {"id": value, "name": "alice"}}
|
||||
elif field == "channel_id":
|
||||
state["channels"] = {value: {"id": value, "name": "general"}}
|
||||
state["messages"] = {value: state["messages"]["C001"]}
|
||||
elif field == "file_id":
|
||||
state["messages"]["C001"][0]["files"][0]["id"] = value
|
||||
elif field == "timestamp":
|
||||
state["messages"]["C001"][0]["ts"] = value
|
||||
elif field == "status_emoji":
|
||||
state["users"]["U001"]["profile"]["status_emoji"] = value
|
||||
elif field == "message_type":
|
||||
state["messages"]["C001"][0]["type"] = value
|
||||
elif field == "message_subtype":
|
||||
state["messages"]["C001"][0]["subtype"] = value
|
||||
elif field == "file_mode":
|
||||
state["messages"]["C001"][0]["files"][0]["mode"] = value
|
||||
|
||||
with pytest.raises(ValidationError, match=match):
|
||||
SlackState.model_validate(state)
|
||||
|
||||
|
||||
def _valid_relationship_state() -> dict[str, Any]:
|
||||
return {
|
||||
"bot_user_id": "U_MOCK_BOT",
|
||||
"users": {
|
||||
"U_MOCK_BOT": {"id": "U_MOCK_BOT", "name": "bot"},
|
||||
"U001": {"id": "U001", "name": "alice"},
|
||||
"U002": {"id": "U002", "name": "bob"},
|
||||
},
|
||||
"channels": {"C001": {"id": "C001", "name": "general"}},
|
||||
"messages": {
|
||||
"C001": [
|
||||
{
|
||||
"type": "message",
|
||||
"text": "parent",
|
||||
"ts": "1700000001.000",
|
||||
"user": "U001",
|
||||
"reply_count": 1,
|
||||
"reply_users": ["U002"],
|
||||
"reply_users_count": 1,
|
||||
"latest_reply": "1700000002.000",
|
||||
"reactions": [{"name": "eyes", "users": ["U_MOCK_BOT"], "count": 1}],
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"text": "reply",
|
||||
"ts": "1700000002.000",
|
||||
"user": "U002",
|
||||
"thread_ts": "1700000001.000",
|
||||
"files": [{"id": "F001", "user": "U002", "channels": ["C001"]}],
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_state_accepts_consistent_relationships() -> None:
|
||||
assert SlackState.model_validate(_valid_relationship_state()).messages["C001"][0].reply_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutate", "match"),
|
||||
[
|
||||
(lambda state: state.__setitem__("bot_user_id", "U404"), "bot_user_id"),
|
||||
(lambda state: state["messages"]["C001"][0].__setitem__("user", "U404"), "does not reference an existing user"),
|
||||
(lambda state: state["channels"]["C001"].__setitem__("creator", "U404"), "creator"),
|
||||
(
|
||||
lambda state: state["channels"]["C001"].__setitem__(
|
||||
"topic", {"value": "general", "creator": "U404", "last_set": 1}
|
||||
),
|
||||
"topic.creator",
|
||||
),
|
||||
(lambda state: state["messages"]["C001"][0]["reactions"][0]["users"].append("U404"), "reaction user"),
|
||||
(lambda state: state["messages"]["C001"][1]["files"][0].__setitem__("channels", ["C404"]), "file channel"),
|
||||
(lambda state: state["messages"]["C001"][0].__setitem__("reply_count", 2), "reply_count"),
|
||||
(lambda state: state["messages"]["C001"][0].__setitem__("latest_reply", "1700000003.000"), "latest_reply"),
|
||||
(
|
||||
lambda state: state["messages"]["C001"][0].update(
|
||||
{"pinned_to": ["C001"], "pinned_info": {"C001": {"pinned_by": "U404", "pinned_ts": 1}}}
|
||||
),
|
||||
"pinned_by",
|
||||
),
|
||||
(
|
||||
lambda state: state["messages"]["C001"][0].update(
|
||||
{"pinned_to": ["C001"], "pinned_info": {"C404": {"pinned_by": "U001", "pinned_ts": 1}}}
|
||||
),
|
||||
"pinned_info contains channels",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_state_rejects_inconsistent_relationships(mutate, match: str) -> None:
|
||||
state = _valid_relationship_state()
|
||||
mutate(state)
|
||||
|
||||
with pytest.raises(ValidationError, match=match):
|
||||
SlackState.model_validate(state)
|
||||
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from helpers import seed_state
|
||||
|
||||
from slack_mock.server import search_messages
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
seed_state(
|
||||
{
|
||||
"channels": {
|
||||
"C001": {"id": "C001", "name": "general", "is_private": False},
|
||||
"C002": {"id": "C002", "name": "engineering", "is_private": False},
|
||||
},
|
||||
"messages": {
|
||||
"C001": [
|
||||
{
|
||||
"ts": "1700000001.000",
|
||||
"text": "Hello world",
|
||||
"user": "U001",
|
||||
"type": "message",
|
||||
"reactions": [{"name": "wave", "users": ["U002"], "count": 1}],
|
||||
"permalink": "https://example.com/message/C001/1700000001.000",
|
||||
},
|
||||
{
|
||||
"ts": "1700000002.000",
|
||||
"text": "Reply in thread",
|
||||
"user": "U002",
|
||||
"type": "message",
|
||||
"thread_ts": "1700000001.000",
|
||||
},
|
||||
{
|
||||
"ts": "1700000003.000",
|
||||
"text": "Another message",
|
||||
"user": "U001",
|
||||
"type": "message",
|
||||
"is_starred": True,
|
||||
},
|
||||
{"ts": "1700000004.000", "text": "Deployment is ready", "user": "U002", "type": "message"},
|
||||
{
|
||||
"ts": "1700000004.500",
|
||||
"text": "Literal from:alice appears in this message",
|
||||
"user": "U002",
|
||||
"type": "message",
|
||||
},
|
||||
],
|
||||
"C002": [
|
||||
{"ts": "1700000005.000", "text": "Hello from engineering", "user": "U002", "type": "message"},
|
||||
{
|
||||
"ts": "1700000006.000",
|
||||
"text": "Code review needed",
|
||||
"user": "U001",
|
||||
"type": "message",
|
||||
"pinned_to": ["C002"],
|
||||
},
|
||||
{
|
||||
"ts": "1700000007.000",
|
||||
"text": "Design handoff link https://example.com/spec",
|
||||
"user": "U003",
|
||||
"type": "message",
|
||||
},
|
||||
{
|
||||
"ts": "1700000008.000",
|
||||
"text": "Multi marker message",
|
||||
"user": "U001",
|
||||
"type": "message",
|
||||
"is_starred": True,
|
||||
"reactions": [{"name": "eyes", "users": ["U002"], "count": 1}],
|
||||
},
|
||||
{
|
||||
"ts": "1700000009.000",
|
||||
"text": "Ambiguous display marker",
|
||||
"user": "U004",
|
||||
"type": "message",
|
||||
},
|
||||
],
|
||||
},
|
||||
"users": {
|
||||
"U001": {
|
||||
"id": "U001",
|
||||
"name": "alice",
|
||||
"real_name": "Alice Smith",
|
||||
"profile": {"display_name": "alice"},
|
||||
},
|
||||
"U002": {"id": "U002", "name": "bob", "real_name": "Bob Jones", "profile": {"display_name": "bob"}},
|
||||
"U003": {
|
||||
"id": "U003",
|
||||
"name": "alicia.smith",
|
||||
"real_name": "Alicia Smith",
|
||||
"profile": {"display_name": "alicia.smith"},
|
||||
},
|
||||
"U004": {
|
||||
"id": "U004",
|
||||
"name": "charlie",
|
||||
"real_name": "Charles Example",
|
||||
"profile": {"display_name": "bob"},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def texts(result: dict) -> list[str]:
|
||||
return [match["text"] for match in result["messages"]["matches"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_matching_scoping_sorting_and_context() -> None:
|
||||
result = await search_messages("Hello")
|
||||
assert result["ok"] is True
|
||||
assert result["messages"]["total"] == 2
|
||||
assert all("hello" in match["text"].lower() for match in result["messages"]["matches"])
|
||||
assert {match["channel"]["id"] for match in result["messages"]["matches"]} == {"C001", "C002"}
|
||||
assert [float(match["ts"]) for match in result["messages"]["matches"]] == sorted(
|
||||
[float(match["ts"]) for match in result["messages"]["matches"]], reverse=True
|
||||
)
|
||||
assert all(match["channel"]["name"] for match in result["messages"]["matches"])
|
||||
assert all(match["user_name"] != "Unknown" for match in result["messages"]["matches"])
|
||||
assert all("username" in match and "display_name" in match for match in result["messages"]["matches"])
|
||||
|
||||
scoped = await search_messages("Hello", channel_id="C001")
|
||||
assert scoped["messages"]["total"] == 1
|
||||
assert scoped["messages"]["matches"][0]["channel"]["id"] == "C001"
|
||||
assert (await search_messages("Hello", channel_id="INVALID"))["error"] == "channel_not_found"
|
||||
assert (await search_messages("zzzznonexistent"))["messages"]["matches"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_and_thread_matching() -> None:
|
||||
by_user_name = await search_messages("alice smith")
|
||||
assert by_user_name["messages"]["total"] > 0
|
||||
assert all(match["user"] == "U001" for match in by_user_name["messages"]["matches"])
|
||||
|
||||
reply = await search_messages("Reply in thread")
|
||||
assert reply["messages"]["total"] == 1
|
||||
assert reply["messages"]["matches"][0]["thread_ts"] == "1700000001.000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagination_limits_and_cursors() -> None:
|
||||
first = await search_messages("Hello", limit=1)
|
||||
assert first["messages"]["total"] == 2
|
||||
assert first["response_metadata"]["next_cursor"]
|
||||
second = await search_messages("Hello", limit=1, cursor=first["response_metadata"]["next_cursor"])
|
||||
assert second["messages"]["matches"][0]["ts"] != first["messages"]["matches"][0]["ts"]
|
||||
|
||||
capped = await search_messages("message", limit=999)
|
||||
assert "limit exceeds the maximum of 100; using 100." in capped["response_metadata"]["warnings"]
|
||||
|
||||
malformed = await search_messages("hello", limit=1, cursor="not-a-number")
|
||||
assert malformed["messages"]["matches"][0]["text"] == "Hello from engineering"
|
||||
assert "Invalid cursor 'not-a-number'; using the first page." in malformed["response_metadata"]["warnings"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_word_and_phrase_semantics() -> None:
|
||||
assert (await search_messages("world hello"))["messages"]["matches"][0]["text"] == "Hello world"
|
||||
assert (await search_messages("hello goodbye"))["messages"]["total"] == 0
|
||||
assert (await search_messages('"hello world"'))["messages"]["matches"][0]["text"] == "Hello world"
|
||||
assert (await search_messages('"world hello"'))["messages"]["total"] == 0
|
||||
assert (await search_messages('needed "code review"'))["messages"]["matches"][0]["text"] == "Code review needed"
|
||||
quoted = await search_messages('"from:alice"')
|
||||
assert quoted["messages"]["matches"][0]["text"] == "Literal from:alice appears in this message"
|
||||
assert quoted["response_metadata"]["warnings"] == []
|
||||
mixed = await search_messages("deployment bob")
|
||||
assert mixed["messages"]["matches"][0]["text"] == "Deployment is ready"
|
||||
assert mixed["messages"]["matches"][0]["user"] == "U002"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_and_user_filters() -> None:
|
||||
assert (await search_messages("in:#engineering hello"))["messages"]["matches"][0]["channel"]["id"] == "C002"
|
||||
assert (await search_messages("in:C001 hello"))["messages"]["matches"][0]["channel"]["id"] == "C001"
|
||||
assert (await search_messages("in:#engineering hello", channel_id="C001"))["error"] == "channel_scope_conflict"
|
||||
assert (await search_messages("in:#nonexistent hello"))["messages"]["total"] == 0
|
||||
assert (await search_messages("in:#engineering hello", channel_id="INVALID"))["error"] == "channel_not_found"
|
||||
|
||||
assert (await search_messages("from:@bob deployment"))["messages"]["matches"][0]["user"] == "U002"
|
||||
assert (await search_messages("from:U001 review"))["messages"]["matches"][0]["text"] == "Code review needed"
|
||||
assert (await search_messages("from:alicia link"))["messages"]["matches"][0]["user"] == "U003"
|
||||
assert {match["user"] for match in (await search_messages("from:@bob"))["messages"]["matches"]} == {"U002"}
|
||||
display_name_match = (await search_messages("from:bob ambiguous"))["messages"]["matches"][0]
|
||||
assert display_name_match["user"] == "U004"
|
||||
assert display_name_match["username"] == "charlie"
|
||||
assert display_name_match["display_name"] == "bob"
|
||||
from_me = await search_messages("from:me hello")
|
||||
assert from_me["messages"]["total"] == 0
|
||||
assert (
|
||||
"from:me is unsupported because caller identity is not available; it will match no users."
|
||||
in from_me["response_metadata"]["warnings"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_date_filters() -> None:
|
||||
result = await search_messages("hello after:1700000002 before:1700000006")
|
||||
assert result["messages"]["matches"][0]["text"] == "Hello from engineering"
|
||||
assert (await search_messages("hello before:1700000005"))["messages"]["matches"][0]["text"] == "Hello world"
|
||||
assert (await search_messages("during:2023-11-14 hello"))["messages"]["total"] == 2
|
||||
assert (await search_messages("during:2023-11 hello"))["messages"]["total"] == 2
|
||||
assert (await search_messages("during:2023 hello"))["messages"]["total"] == 2
|
||||
invalid = await search_messages("during:2023-Q1 hello")
|
||||
assert invalid["messages"]["total"] == 2
|
||||
assert (
|
||||
"Invalid during: date '2023-Q1'. Use YYYY, YYYY-MM, YYYY-MM-DD, or a parseable date."
|
||||
in invalid["response_metadata"]["warnings"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_filters_and_empty_filters() -> None:
|
||||
unsupported = await search_messages("has:calendar hello")
|
||||
assert unsupported["messages"]["total"] == 0
|
||||
assert (
|
||||
"Unsupported has: value 'calendar'. Supported values: link, reaction, star, pin."
|
||||
in unsupported["response_metadata"]["warnings"]
|
||||
)
|
||||
assert (await search_messages("has:link"))["messages"]["matches"][0][
|
||||
"text"
|
||||
] == "Design handoff link https://example.com/spec"
|
||||
assert (await search_messages('has:link "hello world"'))["messages"]["total"] == 0
|
||||
assert set(texts(await search_messages("has:reaction"))) == {"Hello world", "Multi marker message"}
|
||||
assert "Another message" in texts(await search_messages("has:star"))
|
||||
assert (await search_messages("has:pin"))["messages"]["matches"][0]["text"] == "Code review needed"
|
||||
assert (await search_messages("has:reaction has:star"))["messages"]["matches"][0]["text"] == "Multi marker message"
|
||||
assert (await search_messages("has:reaction has:pin"))["messages"]["total"] == 0
|
||||
|
||||
with_text = await search_messages("from: hello")
|
||||
assert with_text["messages"]["total"] == 2
|
||||
assert "Empty from: filter was ignored." in with_text["response_metadata"]["warnings"]
|
||||
assert (await search_messages("from:"))["error"] == "missing_query"
|
||||
assert "Empty in: filter was ignored." in (await search_messages("in:"))["response_metadata"]["warnings"]
|
||||
assert "Empty has: filter was ignored." in (await search_messages("has:"))["response_metadata"]["warnings"]
|
||||
|
||||
combined = await search_messages("in:#general from:@alice has:reaction after:2023-11-14 hello")
|
||||
assert combined["messages"]["total"] == 1
|
||||
assert combined["messages"]["matches"][0]["text"] == "Hello world"
|
||||
@@ -0,0 +1,547 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from helpers import seed_state
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from slack_mock import state as slack_state
|
||||
from slack_mock.server import create_channel, upload_file
|
||||
from slack_mock.state import (
|
||||
delete_message,
|
||||
dump_state,
|
||||
generate_timestamp,
|
||||
get_channel_messages,
|
||||
get_state,
|
||||
get_thread_replies,
|
||||
load_state,
|
||||
merge_inputdir_files,
|
||||
resolve_bundle_output_path,
|
||||
resolve_bundle_state_path,
|
||||
resolve_bundle_state_paths,
|
||||
resolve_input_paths,
|
||||
set_snapshot_paths,
|
||||
state_from_json,
|
||||
state_to_json,
|
||||
write_snapshots,
|
||||
)
|
||||
from slack_mock.viewer import create_slack_viewer_app
|
||||
|
||||
|
||||
def test_resolve_input_paths(tmp_path: Path, monkeypatch) -> None:
|
||||
(tmp_path / "b.json").write_text("{}", encoding="utf-8")
|
||||
(tmp_path / "a.json").write_text("{}", encoding="utf-8")
|
||||
(tmp_path / "readme.txt").write_text("hello", encoding="utf-8")
|
||||
monkeypatch.setenv("INPUTDIR", str(tmp_path))
|
||||
assert resolve_input_paths() == [tmp_path / "a.json", tmp_path / "b.json"]
|
||||
|
||||
monkeypatch.delenv("INPUTDIR")
|
||||
assert resolve_input_paths() == []
|
||||
|
||||
|
||||
def test_resolve_bundle_output_path(monkeypatch) -> None:
|
||||
monkeypatch.setenv("BUNDLE_OUTPUT_DIR", "/some/output/services/slack")
|
||||
assert resolve_bundle_output_path() == Path("/some/output/services/slack/state.json")
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR")
|
||||
assert resolve_bundle_output_path() is None
|
||||
|
||||
|
||||
def test_resolve_bundle_state_path_prefers_state_json(tmp_path: Path, monkeypatch) -> None:
|
||||
"""state.json wins over a bare *.json sibling in the per-service subdir."""
|
||||
service_dir = tmp_path / "services" / "slack"
|
||||
service_dir.mkdir(parents=True)
|
||||
(service_dir / "state.json").write_text("{}", encoding="utf-8")
|
||||
(service_dir / "channels.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
assert resolve_bundle_state_path() == service_dir / "state.json"
|
||||
|
||||
|
||||
def test_resolve_bundle_state_path_globs_when_no_state_json(tmp_path: Path, monkeypatch) -> None:
|
||||
"""The singular back-compat accessor returns the first *.json when there's
|
||||
no state.json. (The loader itself reads the whole folder — see
|
||||
resolve_bundle_state_paths.)"""
|
||||
service_dir = tmp_path / "services" / "slack"
|
||||
service_dir.mkdir(parents=True)
|
||||
(service_dir / "b.json").write_text("{}", encoding="utf-8")
|
||||
(service_dir / "a.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
assert resolve_bundle_state_path() == service_dir / "a.json"
|
||||
|
||||
|
||||
def test_resolve_bundle_state_path_missing_subdir(tmp_path: Path, monkeypatch) -> None:
|
||||
"""A partial bundle without this service's subdir resolves to None so the
|
||||
loader falls back to INPUTDIR."""
|
||||
(tmp_path / "services").mkdir()
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
assert resolve_bundle_state_path() is None
|
||||
monkeypatch.delenv("BUNDLEDIR")
|
||||
assert resolve_bundle_state_path() is None
|
||||
|
||||
|
||||
def test_resolve_bundle_state_paths_returns_whole_folder(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Without state.json the resolver returns ALL *.json (the folder is the
|
||||
unit), not just the first — that's what the loader coalesces."""
|
||||
service_dir = tmp_path / "services" / "slack"
|
||||
service_dir.mkdir(parents=True)
|
||||
(service_dir / "b.json").write_text("{}", encoding="utf-8")
|
||||
(service_dir / "a.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
assert resolve_bundle_state_paths() == [service_dir / "a.json", service_dir / "b.json"]
|
||||
# state.json present → just the canonical single file.
|
||||
(service_dir / "state.json").write_text("{}", encoding="utf-8")
|
||||
assert resolve_bundle_state_paths() == [service_dir / "state.json"]
|
||||
|
||||
|
||||
def _reset_slack_state() -> None:
|
||||
slack_state._current_state = None
|
||||
slack_state._workspaces.clear()
|
||||
slack_state._active_workspace_id = "default"
|
||||
|
||||
|
||||
def test_bundle_multifile_folder_matches_consolidated_state(tmp_path: Path, monkeypatch) -> None:
|
||||
"""A folder of per-entity *.json (no state.json) loads to the SAME merged
|
||||
state as the single consolidated file — no file is dropped."""
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
user = {
|
||||
"id": "U1",
|
||||
"name": "alice",
|
||||
"real_name": "Alice",
|
||||
"profile": {"display_name": "alice", "status_text": "", "status_emoji": ""},
|
||||
"is_bot": False,
|
||||
"deleted": False,
|
||||
}
|
||||
channel = {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}
|
||||
|
||||
# (a) One consolidated state.json.
|
||||
consolidated_dir = tmp_path / "consolidated" / "services" / "slack"
|
||||
consolidated_dir.mkdir(parents=True)
|
||||
(consolidated_dir / "state.json").write_text(
|
||||
json.dumps({"users": {"U1": user}, "channels": {"C1": channel}}), encoding="utf-8"
|
||||
)
|
||||
|
||||
# (b) The same seed split across per-entity files (no state.json).
|
||||
split_dir = tmp_path / "split" / "services" / "slack"
|
||||
split_dir.mkdir(parents=True)
|
||||
(split_dir / "users.json").write_text(json.dumps({"users": {"U1": user}}), encoding="utf-8")
|
||||
(split_dir / "channels.json").write_text(json.dumps({"channels": {"C1": channel}}), encoding="utf-8")
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path / "consolidated"))
|
||||
_reset_slack_state()
|
||||
load_state()
|
||||
consolidated = state_to_json()
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path / "split"))
|
||||
_reset_slack_state()
|
||||
load_state()
|
||||
split = state_to_json()
|
||||
|
||||
assert split == consolidated
|
||||
assert "U1" in split["users"]
|
||||
assert "C1" in split["channels"]
|
||||
|
||||
|
||||
def test_set_snapshot_paths_supports_partial_updates(tmp_path: Path) -> None:
|
||||
seed_state({})
|
||||
final = tmp_path / "final.json"
|
||||
bundle = tmp_path / "services" / "slack" / "state.json"
|
||||
|
||||
set_snapshot_paths(final_path=final)
|
||||
write_snapshots()
|
||||
assert final.exists()
|
||||
assert not bundle.exists()
|
||||
|
||||
set_snapshot_paths(bundle_state_path=bundle)
|
||||
write_snapshots()
|
||||
assert final.exists()
|
||||
assert bundle.exists()
|
||||
|
||||
final.unlink()
|
||||
set_snapshot_paths(final_path=None)
|
||||
write_snapshots()
|
||||
assert not final.exists()
|
||||
assert bundle.exists()
|
||||
|
||||
|
||||
def test_dump_state_writes_overwrites_and_creates_parent_dirs(tmp_path: Path) -> None:
|
||||
seed_state({})
|
||||
dest = tmp_path / "nested" / "dir" / "final.json"
|
||||
dump_state(dest)
|
||||
assert dest.exists()
|
||||
snapshot = json.loads(dest.read_text(encoding="utf-8"))
|
||||
assert {"users", "channels", "messages"}.issubset(snapshot)
|
||||
first = dest.read_text(encoding="utf-8")
|
||||
dump_state(dest)
|
||||
assert dest.read_text(encoding="utf-8") == first
|
||||
|
||||
legacy = tmp_path / "final.json"
|
||||
bundle = tmp_path / "services" / "slack" / "state.json"
|
||||
dump_state(legacy)
|
||||
dump_state(bundle)
|
||||
assert legacy.read_text(encoding="utf-8") == bundle.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_delete_message_cascades_thread_replies() -> None:
|
||||
seed_state(
|
||||
{
|
||||
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
|
||||
"messages": {
|
||||
"C1": [
|
||||
{"ts": "1700000001.000", "text": "parent", "user": "U_MOCK_BOT", "type": "message"},
|
||||
{
|
||||
"ts": "1700000002.000",
|
||||
"text": "reply",
|
||||
"user": "U_MOCK_BOT",
|
||||
"type": "message",
|
||||
"thread_ts": "1700000001.000",
|
||||
},
|
||||
{"ts": "1700000003.000", "text": "unrelated", "user": "U_MOCK_BOT", "type": "message"},
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
assert delete_message("C1", "1700000001.000") is True
|
||||
assert [message.ts for message in get_channel_messages("C1")] == ["1700000003.000"]
|
||||
assert get_thread_replies("C1", "1700000001.000") == []
|
||||
|
||||
seed_state(
|
||||
{
|
||||
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
|
||||
"messages": {
|
||||
"C1": [
|
||||
{"ts": "1700000001.000", "text": "parent", "user": "U_MOCK_BOT", "type": "message"},
|
||||
{
|
||||
"ts": "1700000002.000",
|
||||
"text": "reply",
|
||||
"user": "U_MOCK_BOT",
|
||||
"type": "message",
|
||||
"thread_ts": "1700000001.000",
|
||||
},
|
||||
{"ts": "1700000003.000", "text": "unrelated", "user": "U_MOCK_BOT", "type": "message"},
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
assert delete_message("C1", "1700000003.000") is True
|
||||
assert [message.ts for message in get_channel_messages("C1")] == ["1700000001.000", "1700000002.000"]
|
||||
assert delete_message("C1", "1700000002.000") is True
|
||||
assert [message.ts for message in get_channel_messages("C1")] == ["1700000001.000"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_id_persistence() -> None:
|
||||
seed_state(
|
||||
{"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}}, "messages": {"C1": []}}
|
||||
)
|
||||
first = await upload_file("C1", "a.txt", "YQ==")
|
||||
snapshot = get_state().model_dump(mode="json", exclude_none=True)
|
||||
state_from_json(snapshot)
|
||||
second = await upload_file("C1", "b.txt", "Yg==")
|
||||
assert second["file"]["id"] != first["file"]["id"]
|
||||
|
||||
state_from_json(
|
||||
{
|
||||
"bot_user_id": "U_MOCK_BOT",
|
||||
"users": {"U_MOCK_BOT": {"id": "U_MOCK_BOT", "name": "bot"}},
|
||||
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
|
||||
"messages": {
|
||||
"C1": [
|
||||
{
|
||||
"ts": "1700000001.000",
|
||||
"text": "old",
|
||||
"user": "U_MOCK_BOT",
|
||||
"type": "message",
|
||||
"files": [{"id": "F050000", "name": "old.txt", "created": 1, "timestamp": 1}],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
assert get_state().counters.fileId == 50000
|
||||
next_file = await upload_file("C1", "next.txt", "Yg==")
|
||||
assert int(next_file["file"]["id"].replace("F", "")) > 50000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_id_persistence() -> None:
|
||||
state_from_json(
|
||||
{
|
||||
"bot_user_id": "U_MOCK_BOT",
|
||||
"users": {"U_MOCK_BOT": {"id": "U_MOCK_BOT", "name": "bot"}},
|
||||
"channels": {"C050000": {"id": "C050000", "name": "general", "context_team_id": "T_MOCK"}},
|
||||
"messages": {"C050000": []},
|
||||
}
|
||||
)
|
||||
assert get_state().counters.channelId == 50001
|
||||
next_channel = await create_channel("next")
|
||||
assert int(next_channel["channel"]["id"].replace("C", "")) > 50000
|
||||
|
||||
|
||||
def test_generated_timestamps_are_monotonic_after_seeded_state(monkeypatch) -> None:
|
||||
state_from_json(
|
||||
{
|
||||
"bot_user_id": "U_MOCK_BOT",
|
||||
"users": {"U_MOCK_BOT": {"id": "U_MOCK_BOT", "name": "bot"}},
|
||||
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
|
||||
"messages": {
|
||||
"C1": [
|
||||
{
|
||||
"ts": "2000000000.999999",
|
||||
"text": "seeded future message",
|
||||
"user": "U_MOCK_BOT",
|
||||
"type": "message",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr("slack_mock.state.time.time", lambda: 1_700_000_001.123456)
|
||||
|
||||
generated = [generate_timestamp() for _ in range(5)]
|
||||
|
||||
assert generated == [
|
||||
"2000000001.000000",
|
||||
"2000000001.000001",
|
||||
"2000000001.000002",
|
||||
"2000000001.000003",
|
||||
"2000000001.000004",
|
||||
]
|
||||
|
||||
|
||||
def test_state_import_replaces_timestamp_cursor(monkeypatch) -> None:
|
||||
state_from_json(
|
||||
{
|
||||
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
|
||||
"messages": {
|
||||
"C1": [{"ts": "2000000000.000000", "text": "future", "user": "U_MOCK_BOT", "type": "message"}]
|
||||
},
|
||||
}
|
||||
)
|
||||
state_from_json(
|
||||
{
|
||||
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
|
||||
"messages": {"C1": [{"ts": "1700000001.000000", "text": "older", "user": "U_MOCK_BOT", "type": "message"}]},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr("slack_mock.state.time.time", lambda: 1_700_000_001.123456)
|
||||
|
||||
assert generate_timestamp() == "1700000001.123456"
|
||||
|
||||
|
||||
def test_merge_inputdir_files_combines_workspace_shaped_files(tmp_path: Path) -> None:
|
||||
first = tmp_path / "a.json"
|
||||
second = tmp_path / "b.json"
|
||||
first.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"workspaces": {
|
||||
"acme": {
|
||||
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
|
||||
"messages": {},
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
second.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"workspaces": {
|
||||
"globex": {
|
||||
"channels": {"C2": {"id": "C2", "name": "ops", "context_team_id": "T_MOCK"}},
|
||||
"messages": {},
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
merged = merge_inputdir_files([first, second])
|
||||
|
||||
assert set(merged["workspaces"]) == {"acme", "globex"}
|
||||
|
||||
|
||||
def seed_viewer_state() -> None:
|
||||
seed_state(
|
||||
{
|
||||
"channels": {
|
||||
"C001": {
|
||||
"id": "C001",
|
||||
"name": "general",
|
||||
"topic": {"value": "General chat"},
|
||||
"purpose": {"value": "Team comms"},
|
||||
"is_private": False,
|
||||
"is_archived": False,
|
||||
"num_members": 5,
|
||||
},
|
||||
"C002": {
|
||||
"id": "C002",
|
||||
"name": "random",
|
||||
"topic": {"value": ""},
|
||||
"purpose": {"value": ""},
|
||||
"is_private": False,
|
||||
"is_archived": False,
|
||||
"num_members": 3,
|
||||
},
|
||||
},
|
||||
"messages": {
|
||||
"C001": [
|
||||
{
|
||||
"ts": "1700000001.000",
|
||||
"text": "Hello world",
|
||||
"user": "U001",
|
||||
"type": "message",
|
||||
"reply_count": 1,
|
||||
"reactions": [{"name": "wave", "users": ["U002", "U001", "U_MOCK_BOT"], "count": 3}],
|
||||
},
|
||||
{
|
||||
"ts": "1700000002.000",
|
||||
"text": "Reply in thread",
|
||||
"user": "U002",
|
||||
"type": "message",
|
||||
"thread_ts": "1700000001.000",
|
||||
},
|
||||
{
|
||||
"ts": "1700000003.000",
|
||||
"text": "Another message",
|
||||
"user": "U001",
|
||||
"type": "message",
|
||||
"files": [{"id": "F100", "name": "report.txt", "mimetype": "text/plain", "size": 11}],
|
||||
},
|
||||
],
|
||||
"C002": [],
|
||||
},
|
||||
"users": {
|
||||
"U001": {
|
||||
"id": "U001",
|
||||
"name": "alice",
|
||||
"real_name": "Alice Smith",
|
||||
"profile": {
|
||||
"display_name": "alice",
|
||||
"title": "Engineer",
|
||||
"email": "alice@test.com",
|
||||
"status_text": "Coding",
|
||||
"status_emoji": ":computer:",
|
||||
},
|
||||
"is_bot": False,
|
||||
"deleted": False,
|
||||
},
|
||||
"U002": {
|
||||
"id": "U002",
|
||||
"name": "bob",
|
||||
"real_name": "Bob Jones",
|
||||
"profile": {
|
||||
"display_name": "bob",
|
||||
"title": "Manager",
|
||||
"email": "bob@test.com",
|
||||
"status_text": "",
|
||||
"status_emoji": "",
|
||||
},
|
||||
"is_bot": False,
|
||||
"deleted": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_viewer_api() -> None:
|
||||
seed_viewer_state()
|
||||
client = TestClient(create_slack_viewer_app())
|
||||
|
||||
channels = client.get("/api/channels")
|
||||
assert channels.status_code == 200
|
||||
assert [channel["name"] for channel in channels.json()["channels"]] == ["general", "random"]
|
||||
general = next(channel for channel in channels.json()["channels"] if channel["id"] == "C001")
|
||||
assert general["messageCount"] == 3
|
||||
|
||||
messages = client.get("/api/channels/C001/messages")
|
||||
assert messages.status_code == 200
|
||||
assert messages.json()["channel"]["name"] == "general"
|
||||
assert len(messages.json()["messages"]) >= 1
|
||||
first_message = messages.json()["messages"][0]
|
||||
assert {"text", "time", "user_name", "has_thread"}.issubset(first_message)
|
||||
assert client.get("/api/channels/NOPE/messages").status_code == 404
|
||||
assert len(client.get("/api/channels/C001/messages?limit=1").json()["messages"]) <= 1
|
||||
|
||||
thread = client.get("/api/threads/C001/1700000001.000")
|
||||
assert thread.status_code == 200
|
||||
assert isinstance(thread.json()["messages"], list)
|
||||
|
||||
users = client.get("/api/users")
|
||||
assert users.status_code == 200
|
||||
assert len(users.json()["users"]) == 3
|
||||
alice = next(user for user in users.json()["users"] if user["id"] == "U001")
|
||||
assert alice["display_name"] == "alice"
|
||||
assert alice["title"] == "Engineer"
|
||||
|
||||
|
||||
def test_viewer_serves_html_index() -> None:
|
||||
seed_viewer_state()
|
||||
client = TestClient(create_slack_viewer_app())
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
# Body is loaded from the sibling viewer.html asset, not inlined in the module.
|
||||
assert "<title>Slack</title>" in response.text
|
||||
|
||||
|
||||
def test_viewer_surfaces_message_file_metadata() -> None:
|
||||
seed_viewer_state()
|
||||
client = TestClient(create_slack_viewer_app())
|
||||
|
||||
messages = client.get("/api/channels/C001/messages").json()["messages"]
|
||||
with_files = next(message for message in messages if message.get("files"))
|
||||
file_obj = with_files["files"][0]
|
||||
assert file_obj["name"] == "report.txt"
|
||||
assert file_obj["size"] == 11
|
||||
# Only display metadata is surfaced; stored bytes are never exposed.
|
||||
assert "content_base64" not in file_obj
|
||||
|
||||
home = client.get("/")
|
||||
assert home.status_code == 200
|
||||
assert "html" in home.headers["content-type"]
|
||||
assert "channel-list" in home.text
|
||||
assert "message-pane" in home.text
|
||||
|
||||
|
||||
def test_viewer_derives_thread_count_when_reply_metadata_missing() -> None:
|
||||
# Hand-authored seed: parent has replies but omits reply_count (as the
|
||||
# sample bundle's C004 thread does). The viewer must still surface the
|
||||
# thread so the reply -- filtered out of the main list -- stays reachable.
|
||||
seed_state(
|
||||
{
|
||||
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
|
||||
"messages": {
|
||||
"C1": [
|
||||
{"ts": "1700000001.000", "text": "parent", "user": "U_MOCK_BOT", "type": "message"},
|
||||
{
|
||||
"ts": "1700000002.000",
|
||||
"text": "reply",
|
||||
"user": "U_MOCK_BOT",
|
||||
"type": "message",
|
||||
"thread_ts": "1700000001.000",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
client = TestClient(create_slack_viewer_app())
|
||||
|
||||
messages = client.get("/api/channels/C1/messages").json()["messages"]
|
||||
# The reply is filtered out; only the parent remains in the main list.
|
||||
assert [message["ts"] for message in messages] == ["1700000001.000"]
|
||||
parent = messages[0]
|
||||
assert parent["has_thread"] is True
|
||||
assert parent["reply_count"] == 1
|
||||
|
||||
# The thread endpoint can still return the parent + reply.
|
||||
thread = client.get("/api/threads/C1/1700000001.000").json()["messages"]
|
||||
assert [message["ts"] for message in thread] == ["1700000001.000", "1700000002.000"]
|
||||
Reference in New Issue
Block a user