Metadata-Version: 2.4
Name: dooray-sdk
Version: 0.1.0b1
Summary: Python SDK for Dooray.com with Socket Mode support
Author-email: Dooray SDK Team <dooray-sdk@nhndooray.com>
License: MIT
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.25.0
Requires-Dist: websocket-client>=1.0.0
Requires-Dist: python-dotenv>=1.0.0
Requires-Dist: aiohttp>=3.8.0
Provides-Extra: aiohttp
Requires-Dist: aiohttp>=3.8.0; extra == "aiohttp"
Provides-Extra: websockets
Requires-Dist: websockets>=10.0; extra == "websockets"
Provides-Extra: templates
Requires-Dist: cookiecutter>=2.0.0; extra == "templates"
Provides-Extra: llm
Requires-Dist: langchain-core>=0.3.0; extra == "llm"
Requires-Dist: langchain-openai>=0.3.0; extra == "llm"
Requires-Dist: pydantic>=2.0.0; extra == "llm"
Provides-Extra: all
Requires-Dist: aiohttp>=3.8.0; extra == "all"
Requires-Dist: cookiecutter>=2.0.0; extra == "all"
Requires-Dist: langchain-core>=0.3.0; extra == "all"
Requires-Dist: langchain-openai>=0.3.0; extra == "all"
Requires-Dist: pydantic>=2.0.0; extra == "all"
Dynamic: license-file

# Dooray! SDK - Python

A Python SDK for building agents on Dooray!. This document covers the core classes, decorators, and usage patterns.

**Highlights**

- One SDK surface for the messenger, task, and wiki APIs — plus real-time events from other Dooray! services
- Real-time task / wiki events via the `common` WebSocket (`@agent.on("task", action="createTask")`)
- 13 task and 16 wiki REST methods — 29 in total (`agent.task.create_task(...)`, `agent.wiki.create_page(...)`)
- Unified LLM access for Dooray! AI, OpenAI, and Azure OpenAI (`dooray_sdk.query`)

---

## Installation

### Install via pip

```bash
# Currently in beta (requires --pre flag)
pip install --pre dooray-sdk
```

### Using Virtual Environment (Recommended)

```bash
# Create virtual environment
python3 -m venv .venv

# Activate (Linux/macOS)
source .venv/bin/activate

# Activate (Windows)
.venv\Scripts\activate

# Install
pip install --pre dooray-sdk
```

### Dependencies

Installed automatically with the package:

| Package | Version | Description |
| :------ | :------ | :---------- |
| `requests` | >= 2.25.0 | Sync HTTP client |
| `websocket-client` | >= 1.0.0 | WebSocket client |
| `python-dotenv` | >= 1.0.0 | `.env` file support |
| `aiohttp` | >= 3.8.0 | Async HTTP/WebSocket transport (powers `Agent` real-time runs) |

### Optional Extras

| Extra | Installs | When to use |
| :---- | :------- | :---------- |
| `[aiohttp]` | `aiohttp>=3.8.0` | Included by default since 0.1.0b1 — kept as a backward-compat alias |
| `[templates]` | `cookiecutter>=2.0.0` | `dooray-sdk init` project templates |
| `[llm]` | `langchain-core`, `langchain-openai`, `pydantic` | LLM helpers (`query`, `QueryOptions`) |
| `[all]` | Everything above | Full installation |

The LLM helpers expose a simple `query()` API on top of LangChain — see `examples/ai_agent.py` in the repository for a working agent.

## Quick Start (Templates)

Use the CLI tool to quickly bootstrap your project.

### Install with Templates

```bash
pip install --pre "dooray-sdk[templates]"
```

### Usage

```bash
# List available templates
dooray-sdk list

# Create an echo agent project
dooray-sdk init echo-agent

# Create with default values (no prompts)
dooray-sdk init echo-agent --no-input

# Create in a specific directory
dooray-sdk init echo-agent -o ./projects
```

### Available Templates

| Template | Description |
| :------- | :---------- |
| `echo-agent` | A simple agent that echoes messages back |

## Environment Variables

The SDK uses the following environment variables:

| Variable | Required | Description | Example |
| :------- | :------- | :---------- | :------ |
| `DOORAY_AGENT_TOKEN` | Yes | Agent authentication token | `your_agent_token` |
| `DOORAY_DOMAIN` | Yes | Dooray! domain | `company.dooray.com` |
| `DOORAY_BOT_TOKEN` | No | Fallback token (checked when `DOORAY_AGENT_TOKEN` is unset) | `your_agent_token` |
| `DOORAY_SERVICES` | No | Comma-separated service list (default: `messenger`) | `messenger,common` |

## Classes

### `Agent`

The core class for building Dooray! agents. Supports message handling, auto-replies, and event processing.

```python
class Agent:
    def __init__(
        self,
        token: str = None,
        domain: str = None,
        services: List[str] = None,
        **kwargs
    )
```

#### Constructor Parameters

| Parameter | Type | Default | Description |
| :-------- | :--- | :------ | :---------- |
| `token` | `str \| None` | `None` | Agent token (falls back to `DOORAY_AGENT_TOKEN` env var) |
| `domain` | `str \| None` | `None` | Dooray! domain (falls back to `DOORAY_DOMAIN` env var) |
| `services` | `List[str] \| None` | `["messenger"]` | WebSocket services to connect: `"messenger"` and/or `"common"`. Falls back to the `DOORAY_SERVICES` env var. Task/wiki events arrive over the `common` connection |

#### Properties

| Property | Type | Description |
| :------- | :--- | :---------- |
| `token` | `str` | Agent authentication token |
| `domain` | `str` | Dooray! domain |
| `services` | `List[str]` | Connected service list |
| `base_url` | `str` | API base URL |
| `is_connected` | `bool` | Connection status. Only valid after `run()`/`start()` — accessing it earlier raises `AttributeError` |
| `messenger` | `ServiceNamespace` | Messenger API namespace (`agent.messenger.send_message(...)` etc.) |
| `task` | `ServiceNamespace` | Task API namespace (`agent.task.create_task(...)` etc., 13 methods) |
| `wiki` | `ServiceNamespace` | Wiki API namespace (`agent.wiki.create_page(...)` etc., 16 methods) |

#### Methods

| Method | Description |
| :----- | :---------- |
| `run()` | Start the agent (blocking) |
| `on(event_type, action, service)` | Event handler decorator (preferred) |
| `add_handler(event_type, handler, action, service)` | Register handler (non-decorator) |
| `reply_to(trigger, response)` | Simple response mapping |
| `send_message(channel, text)` | **Deprecated.** Use `agent.messenger.send_message(...)` |

#### Service Namespace Clients

`agent.messenger`, `agent.task`, and `agent.wiki` expose service-scoped API clients that group related methods together (Slack Bolt / atlassian-python-api style).

| Namespace | Method | Description |
| :-------- | :----- | :---------- |
| `agent.messenger` | `send_message(channel, text, **kw)` | Post a new message to a channel/DM |
| `agent.messenger` | `reply_to_message(channel, message_id, text, **kw)` | Quote-reply to a specific message |
| `agent.messenger` | `reply_in_thread(parent_channel_id, text, **kw)` | Reply inside a thread (raises `NotImplementedError` — implementation pending) |
| `agent.task` | `create_task`, `get_task`, `list_tasks`, `modify_task`, `set_done`, ... | Task (project post) CRUD — 13 methods, see [`TaskAPI`](#taskapi--asynctaskapi) |
| `agent.wiki` | `list_wikis`, `create_page`, `get_page`, `modify_page`, `move_page`, ... | Wiki page/comment CRUD — 16 methods, see [`WikiAPI`](#wikiapi--asyncwikiapi) |

The same names also still accept decorator usage (`@agent.messenger` etc.) for backward compatibility, but that path emits a `DeprecationWarning`. Use `@agent.on(...)` instead.

#### Example - Basic Usage (recommended)

Handlers now receive `(req, client)` — `req` carries the event data and `client` (the `Agent` instance) exposes the service namespaces.

```python
from dooray_sdk import Agent

agent = Agent()

@agent.on("message")
async def handle_message(req, client):
    """Handle messenger messages."""
    if req.text:
        await client.messenger.send_message(
            channel=req.channel_info.id,
            text=f"Received: {req.text}",
        )

agent.run()
```

A handler that only accepts `(req)` keeps working for one minor version.

#### Example - Multi-Service

Task and wiki events are delivered over the `common` WebSocket connection — pass `services=["messenger", "common"]` to receive them alongside messenger events.

```python
from dooray_sdk import Agent

agent = Agent(services=["messenger", "common"])

@agent.on("message")
async def handle_messenger(req, client):
    await client.messenger.send_message(
        channel=req.channel_info.id,
        text="This is a messenger message",
    )

@agent.on("task", action="createTask")
async def handle_task_created(req, client):
    # Send a notification message about the new task
    notify_channel = "<channel-id>"
    subject = req.entity.data.subject if req.entity.data else "(no subject)"
    await client.messenger.send_message(
        channel=notify_channel,
        text=f"New task: {subject}",
    )

@agent.on("page")
async def handle_wiki(req, client):
    print(f"Wiki event: {req.action}")

agent.run()
```

#### Example - Reply to a Specific Message

```python
@agent.on("message")
async def handle(req, client):
    if req.text == "quote me":
        await client.messenger.reply_to_message(
            channel=req.channel_info.id,
            message_id=req.entity.data.id,
            text="Quoted reply",
        )
```

### `SocketModeRequest`

Represents an incoming request received via WebSocket.

```python
@dataclass
class SocketModeRequest:
    envelope_id: str
    type: str
    payload: Dict[str, Any]
    accepts_response_payload: bool = False
    service: str = "messenger"
    action: str = ""                                # wrapped into ActionWrapper
    entity: Any = None                              # wrapped into EntityWrapper
    actor: Any = None                               # wrapped into ActorWrapper
    action_data: Optional[Dict[str, Any]] = None    # internal; access via action.data
    channel_data: Optional[Dict[str, Any]] = None   # access via channel_info
```

Raw dicts passed to `entity`, `actor`, and `action` are automatically converted to wrapper objects (`EntityWrapper`, `ActorWrapper`, `ActionWrapper`) in `__post_init__`.

#### Properties

| Property | Type | Description |
| :------- | :--- | :---------- |
| `envelope_id` | `str` | Unique message ID |
| `type` | `str` | Event type (`message`, `task`, `taskComment`, `page`, `pageComment`, etc.) |
| `payload` | `Dict[str, Any]` | Raw payload data |
| `service` | `str` | Service name (`messenger`, `task`, `wiki`) |
| `action` | `ActionWrapper` | Action info; compares equal to strings (`req.action == "create"`) |
| `entity` | `EntityWrapper` | Entity information wrapper |
| `actor` | `ActorWrapper` | Actor information wrapper |

#### Convenience Properties

| Property | Type | Description |
| :------- | :--- | :---------- |
| `is_message` | `bool` | Whether this is a message type |
| `text` | `str \| None` | Message text |
| `channel_info` | `ChannelInfo \| None` | Channel info (`id`, `scope`, `type`). `None` when the raw event has no `channel` field |
| `channel` | `str \| None` | **Deprecated.** Use `channel_info.id` |
| `data` | `Dict[str, Any]` | Raw data (alias for payload) |

#### Methods

| Method | Description |
| :----- | :---------- |
| `reply(text)` | **Deprecated.** Delegates to `client.messenger.send_message(channel=req.channel_info.id, text=text)` with a `DeprecationWarning`. Use the `(req, client)` handler signature and `client.messenger.<method>(...)` directly. |
| `is_message` / `is_task` / `is_page` | Type check properties |
| `is_action_type(action)` | Check action (accepts a string or a list of strings). `req.action == "create"` also works |
| `is_service(service)` | Check service |
| `to_dict()` | Convert to dictionary |
| `from_dict(data)` | Create from dictionary (class method) |

`SocketModeRequest` is now a pure event-data object — action methods have moved to the service namespace clients (`client.messenger.*`).

#### Example - Request Handling

```python
@agent.on("message")
async def handle(req, client):
    # Check message type
    if req.is_message:
        print(f"Text: {req.text}")
        print(f"Channel: {req.channel_info.id}")

    # Check action
    if req.action == "create":       # or req.is_action_type("create")
        print("This is a new message")

    # Check service
    if req.is_service("messenger"):
        await client.messenger.send_message(
            channel=req.channel_info.id,
            text="Responding from messenger",
        )
```

### `WebClient`

Synchronous REST API client (HTTP transport).

```python
class WebClient:
    def __init__(
        self,
        token: str,
        base_url: str = "https://api.dooray.com",
        timeout: int = 30
    )
```

#### Service Namespace

| Namespace | Type | Description |
| :-------- | :--- | :---------- |
| `client.messenger` | `MessengerAPI` | Messenger service methods (`send_message`, `reply_to_message`, `reply_in_thread`) |

#### Methods

| Method | Return Type | Description |
| :----- | :---------- | :---------- |
| `get_member(member_id)` | `Dict` | Get organization member info |
| `send_message(channel, text, **kwargs)` | `Dict` | **Deprecated.** Use `client.messenger.send_message(...)`. |
| `get_channels(**kwargs)` | `Dict` | **Deprecated.** Will move under `client.messenger.*` in a future release. |
| `get_messages(channel, limit, **kwargs)` | `Dict` | **Deprecated.** Will move under `client.messenger.*` in a future release. |
| `get_user_info(user_id)` | `Dict` | **Deprecated.** Will move under `client.messenger.*` in a future release. |

### `AsyncWebClient`

Asynchronous REST API client (HTTP transport).

```python
class AsyncWebClient:
    def __init__(
        self,
        token: str,
        base_url: str = "https://api.dooray.com",
        timeout: int = 30
    )
```

#### Service Namespace

| Namespace | Type | Description |
| :-------- | :--- | :---------- |
| `client.messenger` | `AsyncMessengerAPI` | Messenger service methods (`send_message`, `reply_to_message`, `reply_in_thread`) |

#### Methods

| Method | Return Type | Description |
| :----- | :---------- | :---------- |
| `get_member(member_id)` | `Awaitable[Dict]` | Get organization member info |
| `send_message(channel, text, **kwargs)` | `Awaitable[Dict]` | **Deprecated.** Use `client.messenger.send_message(...)`. |
| `get_channels(**kwargs)` | `Awaitable[Dict]` | **Deprecated.** Will move under `client.messenger.*` in a future release. |
| `get_messages(channel, limit, **kwargs)` | `Awaitable[Dict]` | **Deprecated.** Will move under `client.messenger.*` in a future release. |
| `get_user_info(user_id)` | `Awaitable[Dict]` | **Deprecated.** Will move under `client.messenger.*` in a future release. |

### `MessengerAPI` / `AsyncMessengerAPI`

Messenger service clients. Reached via `agent.messenger`, `WebClient.messenger`, or `AsyncWebClient.messenger`.

| Method | Description | HTTP |
| :----- | :---------- | :--- |
| `send_message(channel, text, **kw)` | Post a new message to a channel/DM | `POST /messenger/v1/channels/{channel}/logs` |
| `reply_to_message(channel, message_id, text, **kw)` | Quote-reply to a specific message | `POST /messenger/v1/channels/{channel}/logs/{message_id}/reply` |
| `reply_in_thread(parent_channel_id, text, **kw)` | Reply inside a thread | Not implemented yet — raises `NotImplementedError`. Implementation pending. |

Each method is provided in both sync (`MessengerAPI`) and async (`AsyncMessengerAPI`) flavours.

### `TaskAPI` / `AsyncTaskAPI`

Task (project post) service clients — 13 methods. Reached via `agent.task.<method>(...)` inside a running agent, or constructed directly with a transport (`TaskAPI(WebClient(...))` / `AsyncTaskAPI(AsyncWebClient(...))`). Both classes are importable from `dooray_sdk`.

| Method | Description |
| :----- | :---------- |
| `create_task(project_id, subject, body, **kw)` | Create a task. A `str` body is auto-wrapped as `text/x-markdown` |
| `get_task(project_id, post_id)` | Get task details |
| `get_task_by_id(post_id)` | Get a task without a project ID |
| `list_tasks(project_id, *, page=0, size=20, **filters)` | List tasks. `size` is clamped to 100 with a `UserWarning` |
| `modify_task(project_id, post_id, *, version=None, **kw)` | Modify a task (`version` enables optimistic locking) |
| `set_workflow(project_id, post_id, workflow_id)` | Change the task workflow state |
| `set_assignee_workflow(project_id, post_id, member_id, workflow_id)` | Change one assignee's state (`member_id="me"` allowed) |
| `set_done(project_id, post_id)` | Mark the task as done |
| `create_comment(project_id, post_id, body, *, attach_file_ids=None)` | Create a comment |
| `list_comments(project_id, post_id, *, page=0, size=20, order="createdAt")` | List comments |
| `get_comment(project_id, post_id, log_id)` | Get one comment |
| `modify_comment(project_id, post_id, log_id, body, *, attach_file_ids=None)` | Modify a comment |
| `delete_comment(project_id, post_id, log_id)` | Delete a comment |

```python
@agent.on("message")
async def create_task_from_message(req, client):
    result = await client.task.create_task(
        project_id="<project-id>",
        subject="New task from chat",
        body=req.text or "(no text)",   # str is wrapped as text/x-markdown
    )
    print(result)
```

### `WikiAPI` / `AsyncWikiAPI`

Wiki service clients — 16 methods (11 page + 5 comment). Reached via `agent.wiki.<method>(...)`, or constructed directly (`WikiAPI(WebClient(...))` / `AsyncWikiAPI(AsyncWebClient(...))`). Both classes are importable from `dooray_sdk`.

| Method | Description |
| :----- | :---------- |
| `list_wikis(*, page=0, size=20)` | List accessible wikis |
| `create_page(wiki_id, subject, body, *, parent_page_id=None, attach_file_ids=None, referrers=None)` | Create a page |
| `list_pages(wiki_id, *, parent_page_id=None)` | List sibling pages (top-level when `parent_page_id` is omitted) |
| `get_page(wiki_id, page_id)` | Get a page |
| `get_page_by_id(page_id)` | Get a page without a wiki ID |
| `modify_page(wiki_id, page_id, *, subject=None, body=None, referrers=None)` | Modify a page. `None` fields are omitted; all-`None` raises `ValueError` |
| `set_page_title(wiki_id, page_id, subject)` | Update the title only |
| `set_page_content(wiki_id, page_id, body)` | Update the content only |
| `set_page_referrers(wiki_id, page_id, referrers)` | Update the referrers only |
| `move_page(wiki_id, page_id, target_parent_page_id, *, target_wiki_id=None, with_children=True, before_page_id=None)` | Move / reorder a page |
| `delete_page(wiki_id, page_id)` | Delete a page |
| `create_comment(wiki_id, page_id, body)` | Create a comment |
| `list_comments(wiki_id, page_id, *, page=0, size=20)` | List comments |
| `get_comment(wiki_id, page_id, comment_id)` | Get one comment |
| `modify_comment(wiki_id, page_id, comment_id, body)` | Modify a comment |
| `delete_comment(wiki_id, page_id, comment_id)` | Delete a comment |

```python
@agent.on("message")
async def note_to_wiki(req, client):
    page = await client.wiki.get_page(wiki_id="<wiki-id>", page_id="<page-id>")
    await client.wiki.create_comment(
        wiki_id="<wiki-id>",
        page_id="<page-id>",
        body=f"From chat: {req.text}",
    )
```

As with the messenger methods, `body` accepts either a raw `str` (auto-wrapped as `{"mimeType": "text/x-markdown", "content": ...}`) or a dict for full control (e.g. `text/html`). Every method is provided in both sync and async flavours.

## Decorators

### `@agent.on()` Decorator (recommended)

Unified event handler decorator. Use `service=...` and `event_type=...` to scope.

```python
@agent.on("message")                      # all message events
@agent.on(service="messenger")            # all messenger events
@agent.on("task", action="createTask")    # task registration only
@agent.on("page")                         # all wiki page events
```

### Service Decorators (deprecated)

`@agent.messenger`, `@agent.task`, and `@agent.wiki` still work for one minor version but emit a `DeprecationWarning`. They are kept because the same names (`agent.messenger` etc.) now also expose service namespace clients (see above) — the decorator form will be retired in a future release.

```python
@agent.messenger   # deprecated — use @agent.on(service="messenger") or @agent.on("message")
@agent.task        # deprecated — use @agent.on(service="task")
@agent.wiki        # deprecated — use @agent.on(service="wiki") or @agent.on("page")
```

#### `@agent.on()` Signature

```python
def on(
    event_type: Union[str, List[str]] = "all",
    action: Union[str, List[str]] = None,
    service: Union[str, List[str]] = None
) -> Callable
```

| Parameter | Type | Default | Description |
| :-------- | :--- | :------ | :---------- |
| `event_type` | `str \| List[str]` | `"all"` | Event type filter |
| `action` | `str \| List[str] \| None` | `None` | Action filter |
| `service` | `str \| List[str] \| None` | `None` | Service filter |

Handlers can accept `(req, client)` (recommended) or `(req)` (one-minor backward compatibility). `client` is the `Agent` instance — use `client.messenger.<method>(...)` etc. to send.

#### Examples

```python
# Messenger messages only
@agent.on("message")
async def handle_message(req, client):
    pass

# Specific action
@agent.on("message", action="create")
async def handle_new_message(req, client):
    pass

# common service — new task created
@agent.on("task", action="createTask")
async def on_task_created(req, client):
    pass

# common service — wiki page created / referrers added
@agent.on("page", action=["createPage", "addPageUser"])
async def on_wiki_changed(req, client):
    pass

# common service — task comment created
# comment events use event_type "taskComment" — subscribing to "task" does NOT match
@agent.on("taskComment", action="createComment")
async def on_task_comment(req, client):
    pass

# Everything (debugging / unified logging)
@agent.on("all")
async def log_all(req, client):
    pass
```

#### Events delivered via the `common` service

When connected with `Agent(services=["messenger", "common"])`, a single WebSocket delivers task / wiki and other Dooray! events together. Register handlers by event type (`req.type`) and narrow them down with `service` / `action`:

| Event type (`event_type`) | `service` | `action` (as observed) | Description |
| :------------------------ | :-------- | :--------------------- | :---------- |
| `task` | `task` | `createTask` / `addTaskUser` | Task created / assignees·cc added |
| `taskComment` | `task` | `createComment` | Task comment created |
| `page` | `wiki` | `createPage` / `addPageUser` | Wiki page created / referrers added |
| `pageComment` | `wiki` | `createComment` | Wiki page comment created |
| `driveFile` | `drive` | (as published by the server) | Drive file changes |

Handlers are routed by `entity.type`. **Comment events are routed separately with `event_type` `taskComment` / `pageComment`** — `@agent.on("task", action="createComment")` does not match; subscribe with `@agent.on("taskComment", action="createComment")` instead.

> This table reflects **events as published by the server**. The SDK forwards the `service` / `type` / `action` values of `common` payloads as-is without filtering, so new event kinds added on the server side arrive without an SDK update.

## Request Data Structure

The SDK provides wrapper types that enable dot notation access to dictionary data.

### Type Structure Overview

```
SocketModeRequest
├── entity: EntityWrapper      # Entity info (task, page, etc.)
│   ├── type: str              # Entity type
│   └── data: typed wrapper    # message→MessageData, task→TaskData,
│                              # taskComment→TaskCommentData, page→PageData,
│                              # pageComment→PageCommentData, unknown→DataWrapper
├── actor: ActorWrapper        # Action performer info
│   ├── type: str              # Actor type
│   └── data: DataWrapper      # Actor data
└── action: ActionWrapper      # Action details
    ├── type: str              # Action type
    └── data: DataWrapper      # Action data
```

Entity types with typed wrappers (`message` / `task` / `taskComment` / `page` / `pageComment`) support **nested dot access** such as `req.entity.data.project.id` and `req.entity.data.workflow.class_`. Unknown types (`driveFile`, etc.) fall back to the generic `DataWrapper`, which supports one-level dot access only.

### `DataWrapper`

The base class for all wrapper types. Enables dot notation access to dictionary data.

```python
class DataWrapper:
    def __getattr__(self, name: str) -> Any   # Dot notation access
    def __getitem__(self, key: str) -> Any    # Dictionary access
    def get(self, key: str, default=None)     # Safe access
```

**Usage:**

```python
# Dot notation access (one-level fields only on the generic DataWrapper)
data.id
data.subject

# Dictionary access (use this for nested dicts)
data["id"]
data.get("subject", "default")
data["nested"]["value"]
```

The generic `DataWrapper` does not wrap nested dicts, so nested dot access like `data.nested.value` is not available on it. Entity types with typed wrappers (`task`, `page`, etc. — see [Task/Wiki Event Data](#taskwiki-event-data) below) do support nested dot access.

### `EntityWrapper`

Wraps entity information (tasks, pages, etc.).

| Property | Type | Description |
| :------- | :--- | :---------- |
| `type` | `str` | Entity type (e.g., `"task"`, `"taskComment"`, `"page"`, `"pageComment"`) |
| `data` | typed wrapper | Entity detail data — `message`→`MessageData`, `task`→`TaskData`, `taskComment`→`TaskCommentData`, `page`→`PageData`, `pageComment`→`PageCommentData`, unknown types→`DataWrapper` |

**Usage:**

```python
@agent.on("task", action="createTask")
async def handle_task(req, client):
    print(req.entity.type)               # "task"
    data = req.entity.data               # TaskData
    print(data.id)                       # Task ID
    print(data.subject)                  # Task subject
    print(data.project.id)               # Project ID (nested dot access)
    print(data.workflow.class_)          # Workflow class — 'class' is a reserved word, hence class_
    print(data.workflow["class"])        # dict access also supported
    if data.to:
        print(data.to[0].organizationMember.name)  # Assignee name
```

#### Message Data

For messenger messages, `entity.type` is `"message"`.

| Property | Type | Description |
| :------- | :--- | :---------- |
| `id` | `str` | Message ID |
| `channelId` | `str` | Channel ID |
| `senderId` | `str` | Sender member ID |
| `text` | `str` | Message text |
| `sentAt` | `int` | Sent time (timestamp ms) |
| `seq` | `int` | Message sequence number |
| `directMemberId` | `str` | DM recipient member ID |
| `parentChannelId` | `str` | Parent channel ID (threads) |

```python
@agent.on("message")
async def handle(req, client):
    print(req.entity.message.text)
    print(req.entity.message.channelId)
    print(req.entity.message.senderId)
    print(req.entity.message.sentAt)
```

#### Task/Wiki Event Data

For task / wiki events, `entity.data` is wrapped in a typed wrapper that supports nested dot access.

| `entity.type` | Wrapper | Key fields |
| :------------ | :------ | :--------- |
| `task` | `TaskData` | `id`, `number`, `subject`, `createdAt`, `project`(`id`, `code`), `workflow`(`id`, `name`, `class_`), `to[]`/`cc[]`(`organizationMember`/`emailUser`), `files[]` |
| `taskComment` | `TaskCommentData` | `id`, `task`(`id`, `subject`), `project`, `createdAt`, `files[]` — the spec includes no comment body; re-fetch via the API when needed |
| `page` | `PageData` | `id`, `subject`, `createdAt`, `project`, `referrers[]`, `files[]` |
| `pageComment` | `PageCommentData` | `id`, `page`(`id`, `subject`), `project`, `createdAt`, `body`(`mimeType`, `content` — optional), `files[]` |

- The workflow class field is the Python reserved word `class`, so it is exposed as the `workflow.class_` property; `workflow["class"]` dict access also works.
- Fields defined in the spec return safe defaults (`""` / `0` / `[]`) instead of raising when absent, and the `["key"]` / `.get()` dict contract is preserved.
- These wrappers live in the `dooray_sdk.socket_mode.request` module and are not exported at the top level (`dooray_sdk`) — for isinstance checks etc., import them as `from dooray_sdk.socket_mode.request import TaskData`.

```python
@agent.on("taskComment", action="createComment")
async def on_task_comment(req, client):
    c = req.entity.data                    # TaskCommentData
    print(c.task.subject)                  # Subject of the commented task
    print(c.project.code)                  # Project code
    for f in c.files:
        print(f.name, f.size)              # Attached files
```

### `ActorWrapper`

Wraps information about the user who performed the action.

| Property | Type | Description |
| :------- | :--- | :---------- |
| `type` | `str` | Actor type (e.g., `"organizationMember"`) |
| `data` | `DataWrapper` | Actor detail data |
| `organizationMember` | `LazyMemberData \| None` | Organization member info (supports lazy loading) |

**Usage:**

```python
@agent.on("message")
async def handle(req, client):
    if req.actor:
        print(req.actor.type)         # "organizationMember"

        # Access via organizationMember (recommended)
        member = req.actor.organizationMember
        print(member.id)              # Member ID (local data, no API call)
        name = await member.name      # Member name (API call when needed)
        print(name)
```

Inside an async handler, always fetch remote fields with `await member.<field>`. Sync access (print / comparison) only works for fields present in the local data or already fetched via `await`; sync-accessing an unfetched field inside an async handler raises `RuntimeError` (see [Lazy Loading](#lazy-loading) below).

#### organizationMember Fields

| Property | Type | Description |
| :------- | :--- | :---------- |
| `id` | `str` | Member ID |
| `name` | `str` | Member name |
| `externalEmailAddress` | `str` | Email address |
| `nickname` | `str` | Nickname |
| `englishName` | `str` | English name |
| `nativeName` | `str` | Native name |
| `userCode` | `str` | User code |
| `locale` | `str` | Locale |
| `timezoneName` | `str` | Timezone |

#### Lazy Loading

`organizationMember` returns data included in the WebSocket message immediately, and fetches missing fields via API when accessed.

```python
@agent.on("message")
async def handle(req, client):
    member = req.actor.organizationMember

    # Local data (no API call)
    member_id = member.id

    # Async access - API call when needed (always use this form in async handlers)
    name = await member.name
    email = await member.externalEmailAddress

    # Sync access (print, comparison, etc.) — only for fields present in
    # local data or already fetched via await, as above
    print(member.name)
    if member.name == "John Doe":
        await client.messenger.send_message(
            channel=req.channel_info.id, text="Hello!"
        )

    # Get full data
    full_data = await member
```

> **Caution**: Sync-accessing a remote field that has not been fetched yet inside
> an async handler raises `RuntimeError: Sync access not available in async context`.
> Use `await member.<field>` in async contexts. Sync access that triggers an API
> call only works outside async contexts (plain sync code).

### `ActionWrapper`

Wraps action detail information.

| Property | Type | Description |
| :------- | :--- | :---------- |
| `type` | `str` | Action type (e.g., `"create"`, `"update"`) |
| `data` | `DataWrapper` | Action detail data |

## Usage Examples

### Echo Agent

```python
from dooray_sdk import Agent

agent = Agent()

@agent.on("message")
async def echo(req, client):
    if req.text:
        await client.messenger.send_message(
            channel=req.channel_info.id,
            text=f"Echo: {req.text}",
        )

agent.run()
```

### Auto-Reply Agent

```python
from dooray_sdk import Agent

agent = Agent()

# Simple mapping (unchanged)
agent.reply_to("ping", "pong")
agent.reply_to("hello", "Hello there!")

# Custom handler
@agent.on("message")
async def handle(req, client):
    text = req.text or ""
    if text.startswith("help"):
        await client.messenger.send_message(
            channel=req.channel_info.id,
            text="Available commands: ping, hello, help",
        )

agent.run()
```

### Multi-Service Agent

Task/wiki events arrive over the `common` WebSocket connection.

```python
from dooray_sdk import Agent

agent = Agent(services=["messenger", "common"])

@agent.on("message")
async def on_message(req, client):
    print(f"[Messenger] {req.text}")
    await client.messenger.send_message(
        channel=req.channel_info.id, text="Message received!"
    )

@agent.on("task")
async def on_task(req, client):
    print(f"[Task] {req.action}: {req.entity.data.subject}")

@agent.on("page")
async def on_wiki(req, client):
    print(f"[Wiki] {req.action}: {req.entity.data.subject}")

@agent.on("all")
async def on_any(req, client):
    print(f"[All] {req.service}/{req.type}/{req.action}")

agent.run()
```

### Reply to a Specific Message (Quote Reply)

```python
@agent.on("message")
async def handle(req, client):
    if req.text == "quote me":
        await client.messenger.reply_to_message(
            channel=req.channel_info.id,
            message_id=req.entity.data.id,
            text="This is a quoted reply",
        )
```

## Error Handling

### Common Exceptions

All SDK exceptions inherit from `DooraySDKError` and are importable from `dooray_sdk`.

| Exception | Cause | Solution |
| :-------- | :---- | :------- |
| `DoorayConfigurationError` | Token/domain not set or invalid | Set `DOORAY_AGENT_TOKEN` and `DOORAY_DOMAIN` |
| `DoorayConnectionError` | WebSocket connection failed | Check network and token. (`DoorayWebSocketError` is defined but not currently raised by the SDK) |
| `RuntimeError` | Calling reply before agent starts | Use after `agent.run()` |

### Exception Handling Example

```python
from dooray_sdk import Agent, DoorayConfigurationError

try:
    agent = Agent()
except DoorayConfigurationError as e:
    print(f"Configuration error: {e}")
    print("Please set DOORAY_AGENT_TOKEN and DOORAY_DOMAIN")
    exit(1)

@agent.on("message")
async def handle(req, client):
    try:
        # Business logic
        process_message(req)
    except Exception as e:
        # Log error (avoid exposing errors to users)
        logger.error(f"Processing error: {e}")

agent.run()
```

## Migration Guide (Service Namespace Refactor)

Starting with this release, the SDK separates **event data** from **action methods** and groups actions under per-service namespaces. Older surfaces keep working for one minor version with `DeprecationWarning`; the next minor release will remove them.

### Handler signature

```python
# Before (deprecated; still works for one minor)
@agent.messenger
async def handle(req):
    await req.reply("pong")

# After
@agent.on("message")
async def handle(req, client):
    await client.messenger.send_message(
        channel=req.channel_info.id, text="pong"
    )
```

### Three messenger methods

`req.reply()` historically meant three different things in Dooray!. Each is now its own method:

| Intent | New method |
| :----- | :--------- |
| Send a fresh message to a channel/DM | `client.messenger.send_message(channel, text)` |
| Quote-reply to a specific message | `client.messenger.reply_to_message(channel, message_id, text)` |
| Reply inside a thread | `client.messenger.reply_in_thread(parent_channel_id, text)` *(implementation pending — raises `NotImplementedError`)* |

### High availability (active/standby)

The SDK transparently supports running multiple agent processes against the same token: the server enforces one active WebSocket per token (close code `1008 AGENT_ALREADY_CONNECTED`), and the SDK puts duplicates into a `STANDBY` state that retries every 15 seconds. When the active process exits, a standby is promoted automatically — no extra configuration required.

### Summary of deprecated surfaces

| Old | New |
| :-- | :-- |
| `req.reply(text)` | `await client.messenger.send_message(channel=req.channel_info.id, text=text)` |
| `@agent.messenger` / `@agent.task` / `@agent.wiki` (decorator) | `@agent.on(service="messenger")` (or `@agent.on("message")`, `@agent.on("task")`, `@agent.on("page")`) |
| `Agent.send_message / get_channels / get_messages / get_user_info` | `agent.messenger.send_message(...)` (and future `agent.messenger.*` methods) |
| `WebClient / AsyncWebClient .send_message / .get_channels / .get_messages / .get_user_info` | `client.messenger.<method>(...)` |

Run under `python -W error::DeprecationWarning` to discover remaining call sites.

## License

MIT License
