Add Handbook.md benchmark tasks

This commit is contained in:
DerekSurge
2026-06-24 12:44:34 -07:00
commit 25c9eda5ca
1800 changed files with 323575 additions and 0 deletions
+55
View File
@@ -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`.
+37
View File
@@ -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
+118
View File
@@ -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"
]
}
}
+39
View File
@@ -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()">&times;</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")
+22
View File
@@ -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)
+27
View File
@@ -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"]