From 5898f57c040f4aa3603f91a16136bc5e1b7b235c Mon Sep 17 00:00:00 2001 From: ArcSolver Date: Mon, 1 Jun 2026 18:42:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20telegram=C2=B7discord=C2=B7line=20?= =?UTF-8?q?=ED=95=B5=EC=8B=AC=20API=20=EB=8F=84=EA=B5=AC=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20(4=EC=84=9C=EB=B9=84=EC=8A=A4=C2=B75=E2=86=9219?= =?UTF-8?q?=EB=8F=84=EA=B5=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 각 서비스 MVP(전송 1개)를 공식 핵심 API까지 확장. 머지 전 독립·적대적 검증 통과. 코어: - http: patch_json / delete_json 동사 추가(편집·삭제 패턴 공용) telegram (+5): get_me(헬스체크) · send_photo · send_document(URL·file_id) · edit_message_text · delete_message discord (+5): send_embed · edit_message · delete_message(Webhook) · create_message · list_messages(Bot 토큰, DISCORD_BOT_TOKEN) line (+4): reply_text · multicast_text(userId≤500) · broadcast_text · get_profile 독립 검증에서 발견·교정: - telegram editMessageText: chat_id/message_id를 무조건 필수로 좁혔던 것을 공식의 조건부·상호배타(chat_id+message_id ⊕ inline_message_id)로 충실히 수정 (inline 편집 경로도 지원). - discord: API 버전 주석 정정 + list_messages 우회 코드 정리. 모든 신규 엔드포인트·필드·제약은 공식 문서로 검증(provenance 주석). 새 서드파티 의존 없음. 검증: pytest 94 passed, ruff clean, 카탈로그 19도구. Co-Authored-By: Claude Opus 4.8 --- .env.example | 2 + CHANGELOG.md | 3 + arcsolve/http.py | 32 +++- arcsolve/services/discord/README.md | 77 ++++++-- arcsolve/services/discord/contract.py | 157 ++++++++++++++-- arcsolve/services/discord/tools.py | 244 ++++++++++++++++++++++++- arcsolve/services/line/README.md | 54 ++++-- arcsolve/services/line/__init__.py | 2 +- arcsolve/services/line/contract.py | 84 +++++++++ arcsolve/services/line/tools.py | 133 +++++++++++++- arcsolve/services/telegram/README.md | 50 ++++- arcsolve/services/telegram/__init__.py | 2 +- arcsolve/services/telegram/contract.py | 169 +++++++++++++++-- arcsolve/services/telegram/tools.py | 198 +++++++++++++++++++- changelog.d/discord.md | 1 + changelog.d/line.md | 1 + changelog.d/telegram.md | 1 + docs/providers.md | 36 ++-- docs/services.md | 20 +- tests/test_discord_contract.py | 104 ++++++++++- tests/test_http.py | 24 ++- tests/test_line_contract.py | 142 ++++++++++++++ tests/test_telegram_contract.py | 196 ++++++++++++++++++++ 23 files changed, 1631 insertions(+), 101 deletions(-) diff --git a/.env.example b/.env.example index a7d9699..0ce32cd 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,8 @@ TELEGRAM_CHAT_ID= # ─── Discord ───────────────────────────────────────────── # 채널 설정 > 연동 > 웹후크에서 발급한 전체 Webhook URL (URL 자체가 시크릿) DISCORD_WEBHOOK_URL= +# (선택) Bot 토큰 — 채널 직접 전송/조회(create_message·list_messages)용 +DISCORD_BOT_TOKEN= # ─── LINE ──────────────────────────────────────────────── # LINE Developers 콘솔에서 발급한 채널 액세스 토큰 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d62dc1..4e0062b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,12 @@ - **core**: provenance 강제 테스트 + GitHub Actions CI(pytest·ruff·카탈로그/체인지로그 drift) - **core**: LICENSE(Apache-2.0)·CONTRIBUTING 추가 - **discord**: Webhook 메시지 전송 MCP 추가 — `discord_send_message` +- **discord**: 핵심 도구 확장 — Webhook 임베드/편집/삭제(`discord_send_embed`·`discord_edit_message`·`discord_delete_message`) + Bot 토큰 경로(`discord_create_message`·`discord_list_messages`, `DISCORD_BOT_TOKEN`) - **kakao**: '나에게 보내기' MCP 추가 — `kakao_send_text_to_me`, `kakao_send_link_to_me` - **line**: LINE Messaging API push 텍스트 MCP 추가 — `line_send_text`(전송 메시지 id 반환) - **line**: push 응답 계약을 공식 스펙(`sentMessages[]`)에 맞게 수정, text 길이를 UTF-16 코드 유닛으로 검증 +- **line**: 코어 도구 확장 — `line_reply_text`(reply, sentMessages), `line_multicast_text`(userId 최대 500, 빈 응답), `line_broadcast_text`(빈 응답), `line_get_profile`(Profile 조회) 추가 - **repo**: README 상단 배지(CI · License · Python) 추가, 저장소 public 공개 - **telegram**: sendMessage 기반 telegram_send_message 추가 +- **telegram**: 코어 도구 확장 — getMe(헬스체크)/sendPhoto/sendDocument(URL·file_id만)/editMessageText/deleteMessage 추가 diff --git a/arcsolve/http.py b/arcsolve/http.py index 2bf113b..04c7ada 100644 --- a/arcsolve/http.py +++ b/arcsolve/http.py @@ -1,7 +1,7 @@ """모든 서비스가 공유하는 HTTP 호출 + 에러 매핑. -서비스는 여기 동사(post_form / get_json / post_json)를 재사용하고, 직접 httpx 세션을 -만들지 않는다. 새 인증 방식(Bearer/API-key 등)은 헤더로 주입한다. +서비스는 여기 동사(post_form / get_json / post_json / patch_json / delete_json)를 재사용하고, +직접 httpx 세션을 만들지 않는다. 새 인증 방식(Bearer/API-key 등)은 헤더로 주입한다. """ from __future__ import annotations @@ -96,3 +96,31 @@ async def post_json( return await _request( "POST", url, headers=headers, json=json, timeout=timeout, transport=transport ) + + +async def patch_json( + url: str, + *, + headers: dict | None = None, + json: dict | None = None, + timeout: float = DEFAULT_TIMEOUT, + transport: httpx.BaseTransport | None = None, +) -> dict: + """application/json PATCH → JSON. (리소스 부분 수정, 예: 메시지 편집)""" + return await _request( + "PATCH", url, headers=headers, json=json, timeout=timeout, transport=transport + ) + + +async def delete_json( + url: str, + *, + headers: dict | None = None, + params: dict | None = None, + timeout: float = DEFAULT_TIMEOUT, + transport: httpx.BaseTransport | None = None, +) -> dict: + """DELETE → JSON(또는 본문 없으면 빈 dict). (리소스 삭제, 예: 메시지 삭제)""" + return await _request( + "DELETE", url, headers=headers, params=params, timeout=timeout, transport=transport + ) diff --git a/arcsolve/services/discord/README.md b/arcsolve/services/discord/README.md index cd01e90..accfdd1 100644 --- a/arcsolve/services/discord/README.md +++ b/arcsolve/services/discord/README.md @@ -1,42 +1,79 @@ # Discord 서비스 -Discord **Webhook**으로 채널에 메시지를 전송하는 래퍼. +Discord 채널에 메시지를 보내고(임베드 포함) 편집·삭제하며, Bot 토큰으로 임의 채널에 +메시지를 전송·조회하는 래퍼. 두 인증 경로(Webhook / Bot 토큰)를 지원한다. ## 계약 출처 (공식 문서) - Execute Webhook: https://discord.com/developers/docs/resources/webhook#execute-webhook -- Message Object: https://discord.com/developers/docs/resources/message#message-object +- Edit Webhook Message: https://discord.com/developers/docs/resources/webhook#edit-webhook-message +- Delete Webhook Message: https://discord.com/developers/docs/resources/webhook#delete-webhook-message +- Message Object / Embed: https://discord.com/developers/docs/resources/message#message-object +- Create Message: https://discord.com/developers/docs/resources/message#create-message +- Get Channel Messages: https://discord.com/developers/docs/resources/message#get-channel-messages +- API Base URL / 버전: https://discord.com/developers/docs/reference#api-reference-base-url +- 인증(Bot 토큰 스킴): https://discord.com/developers/docs/reference#authentication +- JSON 에러 코드: https://discord.com/developers/docs/topics/opcodes-and-status-codes#json-json-error-codes > (위 URL은 `https://docs.discord.com/developers/...`로 301 리다이렉트된다.) > 계약 본체는 [`contract.py`](contract.py)에 코드로 박제되어 있다(엔드포인트·요청/응답 모델). ## 엔드포인트 -| 종류 | METHOD · PATH | -|------|------| -| Execute Webhook | `POST /webhooks/{webhook.id}/{webhook.token}` | +| 종류 | METHOD · PATH | 인증 | +|------|------|------| +| Execute Webhook | `POST /webhooks/{webhook.id}/{webhook.token}` | 없음 (Webhook URL이 시크릿) | +| Edit Webhook Message | `PATCH /webhooks/{webhook.id}/{webhook.token}/messages/{message.id}` | 없음 | +| Delete Webhook Message | `DELETE /webhooks/{webhook.id}/{webhook.token}/messages/{message.id}` | 없음 | +| Create Message | `POST /channels/{channel.id}/messages` | `Authorization: Bot ` | +| Get Channel Messages | `GET /channels/{channel.id}/messages?limit=N` | `Authorization: Bot ` | -Base: 전체 Webhook URL을 `DISCORD_WEBHOOK_URL`로 통째로 받는다(id/token이 URL path에 포함) · -인증: **없음** (Webhook URL 자체가 시크릿) · 스코프: 해당 없음 +Base(Bot 토큰 경로): `https://discord.com/api/v10` (v10은 현행 "Available" 버전) · +Webhook 경로: 전체 Webhook URL을 `DISCORD_WEBHOOK_URL`로 통째로 받는다(id/token이 URL path에 포함). + +## 인증 (두 경로) +- **Webhook**: 인증 헤더가 없다. Webhook URL 자체가 시크릿이다. `discord_send_message` / + `discord_send_embed` / `discord_edit_message` / `discord_delete_message`가 이 경로를 쓴다. +- **Bot 토큰**: `Authorization: Bot ` 헤더(Bearer 아님)를 직접 주입한다. + `discord_create_message` / `discord_list_messages`가 이 경로를 쓴다. ## 셋업 +**Webhook 경로** 1. Discord 채널 → **설정 → 연동(Integrations) → 웹후크(Webhooks)**에서 웹후크 생성 2. **웹후크 URL 복사** 3. `.env`에 `DISCORD_WEBHOOK_URL=...` 작성 (인터랙티브 인증 단계 없음) -> 이 서비스는 OAuth가 아니므로 `arcsolve-mcp auth discord`가 없다. URL만 있으면 동작한다. +**Bot 토큰 경로(선택)** +1. [Discord 개발자 포털](https://discord.com/developers/applications)에서 애플리케이션·봇 생성 +2. **봇 토큰 발급** 후 대상 길드에 초대(채널 View/Send 권한 부여) +3. `.env`에 `DISCORD_BOT_TOKEN=...` 작성 + +> 이 서비스는 OAuth가 아니므로 `arcsolve-mcp auth discord`가 없다. +> Webhook 경로는 URL만, Bot 경로는 토큰만 있으면 동작한다. +> `DISCORD_BOT_TOKEN` 미설정 시 Bot 도구는 친절한 설정 안내를 반환한다. ## 도구 -| 도구 | 설명 | -|------|------| -| `discord_send_message(content, username?, avatar_url?, tts?)` | 채널에 메시지 전송(≤2000자) | +| 도구 | 경로 | 설명 | +|------|------|------| +| `discord_send_message(content, username?, avatar_url?, tts?)` | Webhook | 채널에 텍스트 전송(≤2000자) | +| `discord_send_embed(title?, description?, url?, color?, footer?)` | Webhook | 리치 임베드 1개 전송(필드 ≥1 필수) | +| `discord_edit_message(message_id, content?)` | Webhook | Webhook 메시지 본문 편집 | +| `discord_delete_message(message_id)` | Webhook | Webhook 메시지 삭제(성공 204) | +| `discord_create_message(channel_id, content)` | Bot | 임의 채널에 텍스트 전송(≤2000자) | +| `discord_list_messages(channel_id, limit?)` | Bot | 채널 최근 메시지 조회(limit 1–100, 기본 50) | ## 범위 / 제약 -- MVP는 **Webhook 실행(메시지 전송)만**. Webhook은 인증이 없어 가장 단순하다. -- `content`는 최대 **2000자**(공식 제약). 공식 문서상 `content`/`embeds`/`components`/`file`/`poll` - 중 하나는 필수이며, 본 MVP는 `content`만 노출하므로 `content`를 필수로 둔다. -- 도구는 `?wait=true`로 호출해 서버 확인 + 생성된 Message 오브젝트를 받는다(기본 `wait=false`는 - `204 No Content`). +- `content`는 최대 **2000자**(공식 제약, Webhook·Create Message 공통). Nitro 적용 시 4000자지만 + 본 서비스는 표준 2000자 상한을 강제한다. +- `embeds`는 메시지당 **최대 10개**(공식 제약). 본 서비스 `discord_send_embed`는 임베드 1개를 보낸다. + Webhook 임베드는 `type`/`provider`/`video`, 이미지 `height`/`width`/`proxy_url`을 설정할 수 없다(공식). +- `color`는 **RGB 정수**(예: 빨강 `16711680`). `footer`는 텍스트 문자열 1개로 단순화했다. +- 임베드/편집은 표시할 필드가 최소 하나 있어야 한다(없으면 입력 오류 안내). +- 메시지 전송/임베드는 `?wait=true`로 호출해 생성된 Message 오브젝트(특히 `id`)를 받는다 + (기본 `wait=false`는 `204 No Content`). 이 `id`로 편집·삭제가 가능하다. +- 편집·삭제는 **동일 Webhook이 보낸 메시지**에만 적용된다. +- `discord_list_messages`의 `limit`은 **1–100**(공식 제약), 범위를 벗어나면 입력 오류 안내. ## 확장 포인트 -- `embeds`(임베드 카드, 최대 10개) / `allowed_mentions` / `components` 필드: `contract.py`의 - `ExecuteWebhookRequest`에 추가 → `tools.py`에 인자 노출. -- Bot 토큰 경로 [create-message](https://discord.com/developers/docs/resources/message#create-message) - (`Authorization: Bot ` 헤더 사용): 임의 채널 전송이 필요하면 별도 도구로 추가. +- `allowed_mentions` / `components` / `attachments`(파일 업로드, multipart): `contract.py`에 모델 추가 → + `tools.py`에 인자 노출. 파일 업로드는 `multipart/form-data`가 필요해 코어 HTTP 확장이 선행돼야 한다. +- 임베드의 `fields`/`author`/`image`/`thumbnail` 등 더 풍부한 하위 모델: `Embed`에 필드 추가. +- Bot 토큰 경로의 메시지 편집/삭제(`PATCH`/`DELETE /channels/{channel.id}/messages/{message.id}`): + 필요 시 별도 도구로 추가. diff --git a/arcsolve/services/discord/contract.py b/arcsolve/services/discord/contract.py index 89421e3..6f401a9 100644 --- a/arcsolve/services/discord/contract.py +++ b/arcsolve/services/discord/contract.py @@ -1,11 +1,20 @@ -"""Discord Webhook REST API 계약(contract). +"""Discord REST API 계약(contract). 상류 API의 '진실'만 담는다 — 엔드포인트, 인증 요건, 요청/응답 스키마. MCP에 대한 의존성 없음(순수 상수 + pydantic 모델). +두 가지 인증 경로를 모델링한다: + (A) Webhook 경로 — 인증 불필요. Webhook URL 자체가 시크릿(URL path의 {webhook.token}). + (B) Bot 토큰 경로 — `Authorization: Bot ` 헤더로 임의 채널에 접근. + 출처(공식 문서): - - Execute Webhook : https://discord.com/developers/docs/resources/webhook#execute-webhook - - Message Object : https://discord.com/developers/docs/resources/message#message-object + - Execute Webhook : https://discord.com/developers/docs/resources/webhook#execute-webhook + - Edit Webhook Message : https://discord.com/developers/docs/resources/webhook#edit-webhook-message + - Delete Webhook Message : https://discord.com/developers/docs/resources/webhook#delete-webhook-message + - Message Object / Embed : https://discord.com/developers/docs/resources/message#message-object + - Create Message : https://discord.com/developers/docs/resources/message#create-message + - Get Channel Messages : https://discord.com/developers/docs/resources/message#get-channel-messages + - API Reference (base) : https://discord.com/developers/docs/reference#api-reference-base-url (위 URL은 https://docs.discord.com/developers/... 로 301 리다이렉트된다.) """ @@ -14,17 +23,40 @@ from pydantic import BaseModel, Field # ─── 인증 ─────────────────────────────────────────────────── -# Execute Webhook은 "does not require authentication." -# Webhook URL 자체가 시크릿(URL path의 {webhook.token})이라 별도 인증 헤더가 없다. -# 출처: https://discord.com/developers/docs/resources/webhook#execute-webhook - -# ─── 엔드포인트 ────────────────────────────────────────────── +# (A) Execute/Edit/Delete Webhook은 "does not require authentication." +# Webhook URL 자체가 시크릿(URL path의 {webhook.token})이라 별도 인증 헤더가 없다. +# 출처: https://discord.com/developers/docs/resources/webhook#execute-webhook +# (B) Bot 토큰 경로는 `Authorization: Bot ` 헤더를 직접 주입한다(Bearer 아님). +# 출처: https://discord.com/developers/docs/reference#authentication + +# ─── API Base URL ─────────────────────────────────────────── +# "Base URL: https://discord.com/api". 버전은 path에 붙인다(예: /api/v10). +# 버전 미지정 시 default는 구버전(deprecated)이므로 항상 v10을 명시한다("Available" 버전). +# 출처: https://discord.com/developers/docs/reference#api-reference-base-url +# https://discord.com/developers/docs/reference#api-versioning-api-versions +API_BASE_URL = "https://discord.com/api/v10" + +# ─── 엔드포인트 (A) Webhook 경로 ────────────────────────────── # Execute Webhook: POST /webhooks/{webhook.id}/{webhook.token} # 단, 이 서비스는 전체 Webhook URL을 env(DISCORD_WEBHOOK_URL)로 통째로 받으므로 # BASE_URL/PATH 조합 대신 URL을 그대로 사용한다. (URL에 id/token이 이미 포함) # 출처: https://discord.com/developers/docs/resources/webhook#execute-webhook EXECUTE_WEBHOOK_ROUTE = "/webhooks/{webhook_id}/{webhook_token}" +# Edit Webhook Message: PATCH /webhooks/{webhook.id}/{webhook.token}/messages/{message.id} +# Delete Webhook Message: DELETE /webhooks/{webhook.id}/{webhook.token}/messages/{message.id} +# 본 서비스는 Webhook URL 뒤에 `/messages/{message_id}`를 덧붙여 경로를 파생한다. +# 출처: https://discord.com/developers/docs/resources/webhook#edit-webhook-message +# https://discord.com/developers/docs/resources/webhook#delete-webhook-message +WEBHOOK_MESSAGE_SUFFIX = "/messages/{message_id}" + +# ─── 엔드포인트 (B) Bot 토큰 경로 ───────────────────────────── +# Create Message: POST /channels/{channel.id}/messages +# Get Channel Messages: GET /channels/{channel.id}/messages +# 출처: https://discord.com/developers/docs/resources/message#create-message +# https://discord.com/developers/docs/resources/message#get-channel-messages +CHANNEL_MESSAGES_ROUTE = "/channels/{channel_id}/messages" + # 쿼리 파라미터 `wait`(boolean, 기본 false): # "waits for server confirmation of message send before response, # and returns the created message body" @@ -32,22 +64,75 @@ WAIT_PARAM = "wait" # content 길이 제한: "the message contents (up to 2000 characters)" +# Webhook(Execute Webhook)·Create Message 모두 동일한 2000자 상한을 따른다. # 출처: https://discord.com/developers/docs/resources/webhook#execute-webhook-jsonform-params CONTENT_MAX_LENGTH = 2000 +# embeds 배열 상한: "array of up to 10 embed objects" +# 출처: https://discord.com/developers/docs/resources/webhook#execute-webhook-jsonform-params +MAX_EMBEDS = 10 + +# Get Channel Messages `limit` 쿼리 파라미터: "max number of messages to return (1-100)", +# 기본값 50. +# 출처: https://discord.com/developers/docs/resources/message#get-channel-messages-query-string-params +MESSAGES_LIMIT_MIN = 1 +MESSAGES_LIMIT_MAX = 100 +MESSAGES_LIMIT_DEFAULT = 50 + + +class EmbedFooter(BaseModel): + """Embed Footer 구조. + + 필드: + - text: string (필수) · "footer text" + - icon_url: string (선택) · "url of footer icon (only supports http(s) and attachments)" + + 출처: https://discord.com/developers/docs/resources/message#embed-object-embed-footer-structure + """ + + text: str + icon_url: str | None = None + + +class Embed(BaseModel): + """Embed 오브젝트(부분 — 자주 쓰는 필드만 모델링). + + 공식 제약: Webhook으로 보내는 embed는 type/provider/video, 이미지의 height/width/proxy_url을 + 설정할 수 없다(설정해도 rich로 강제된다). 본 모델은 그 외 안전한 필드만 노출한다. + + 필드: + - title: string · "title of embed" + - description: string · "description of embed" + - url: string · "url of embed" + - color: integer · "color code of the embed" (RGB 정수) + - timestamp: ISO8601 string · "timestamp of embed content" + - footer: embed footer object + + 출처: https://discord.com/developers/docs/resources/message#embed-object-embed-structure + """ + + title: str | None = None + description: str | None = None + url: str | None = None + color: int | None = None # integer(RGB) — 공식: "color code of the embed" + timestamp: str | None = None # ISO8601 + footer: EmbedFooter | None = None + class ExecuteWebhookRequest(BaseModel): """Execute Webhook 요청 body (JSON/form params). 공식 제약: "you must provide a value for at least one of content, embeds, - components, file, or poll." 본 MVP는 content만 모델링하므로 content는 필수로 둔다. + components, file, or poll." 본 서비스는 content/embeds를 노출한다. 선택 필드(username/avatar_url/tts)는 문서의 JSON params 표에 실재한다. 출처: https://discord.com/developers/docs/resources/webhook#execute-webhook-jsonform-params """ # content: string · "the message contents (up to 2000 characters)" - content: str = Field(max_length=CONTENT_MAX_LENGTH) + content: str | None = Field(default=None, max_length=CONTENT_MAX_LENGTH) + # embeds: array of up to 10 embed objects · "embedded rich content" + embeds: list[Embed] | None = Field(default=None, max_length=MAX_EMBEDS) # username: string (false) · "override the default username of the webhook" username: str | None = None # avatar_url: string (false) · "override the default avatar of the webhook" @@ -56,18 +141,60 @@ class ExecuteWebhookRequest(BaseModel): tts: bool | None = None +class EditWebhookMessageRequest(BaseModel): + """Edit Webhook Message 요청 body (JSON/form params, 모두 선택). + + "All parameters to this endpoint are optional and nullable." + 본 서비스는 content/embeds만 노출한다. + + 출처: https://discord.com/developers/docs/resources/webhook#edit-webhook-message-jsonform-params + """ + + # content: string · "the message contents (up to 2000 characters)" + content: str | None = Field(default=None, max_length=CONTENT_MAX_LENGTH) + # embeds: array of up to 10 embed objects · "embedded rich content" + embeds: list[Embed] | None = Field(default=None, max_length=MAX_EMBEDS) + + +class CreateMessageRequest(BaseModel): + """Create Message 요청 body (Bot 토큰 경로). + + 공식 제약: "you must provide a value for at least one of content, embeds, + sticker_ids, components, files[n], or poll." 본 서비스는 content만 노출하므로 필수로 둔다. + content는 "up to 2000 characters"(Nitro 미적용 표준 상한)를 따른다. + + 출처: https://discord.com/developers/docs/resources/message#create-message-jsonform-params + """ + + content: str = Field(max_length=CONTENT_MAX_LENGTH) + + +class MessageAuthor(BaseModel): + """Message author(User 오브젝트 부분). + + 필드: + - id: snowflake · "the user's id" + - username: string · "the user's username" + + 출처: https://discord.com/developers/docs/resources/user#user-object-user-structure + """ + + id: str | None = None + username: str | None = None + + class MessageResult(BaseModel): - """`?wait=true`일 때 반환되는 Message 오브젝트(부분). + """Message 오브젝트(부분) — 도구가 확인용으로 읽는 최소 필드만. Execute Webhook은 기본(wait=false) 시 204 No Content를 반환하고, - wait=true일 때 생성된 Message 오브젝트를 반환한다. - 여기서는 도구가 확인용으로 읽는 최소 필드만 모델링한다. + wait=true일 때 생성된 Message 오브젝트를 반환한다. Create Message/Edit Webhook Message는 + Message 오브젝트를 반환한다. 출처(필드 타입): - - Execute Webhook 응답: https://discord.com/developers/docs/resources/webhook#execute-webhook - - Message Structure : https://discord.com/developers/docs/resources/message#message-object-message-structure + - Message Structure : https://discord.com/developers/docs/resources/message#message-object-message-structure """ id: str | None = None # snowflake channel_id: str | None = None # snowflake content: str | None = None # string + author: MessageAuthor | None = None # user object diff --git a/arcsolve/services/discord/tools.py b/arcsolve/services/discord/tools.py index e910148..a4f8b99 100644 --- a/arcsolve/services/discord/tools.py +++ b/arcsolve/services/discord/tools.py @@ -1,9 +1,10 @@ -"""Discord Webhook MCP 도구 + 런타임 배선(자격증명). +"""Discord MCP 도구 + 런타임 배선(자격증명). contract.py의 계약을 실제 MCP 도구로 노출하는 얇은 층. -이 서비스는 인터랙티브 OAuth가 아니다 — Webhook URL 자체가 시크릿이므로 -make_auth_client/oauth.py를 쓰지 않는다. +두 인증 경로를 제공한다: + (A) Webhook 경로 — DISCORD_WEBHOOK_URL. URL 자체가 시크릿이라 인터랙티브 OAuth가 불필요하다. + (B) Bot 토큰 경로 — DISCORD_BOT_TOKEN. `Authorization: Bot ` 헤더로 임의 채널 접근. """ from __future__ import annotations @@ -12,7 +13,7 @@ from pydantic import ValidationError from pydantic_settings import BaseSettings, SettingsConfigDict -from arcsolve.http import UpstreamError, post_json +from arcsolve.http import UpstreamError, delete_json, get_json, patch_json, post_json from arcsolve.services.discord import contract as d @@ -21,18 +22,47 @@ class DiscordSettings(BaseSettings): webhook_url: Discord 채널 설정 → 연동 → 웹후크에서 발급한 전체 URL. URL path에 webhook id/token이 포함되어 있어 별도 인증 헤더가 없다. + bot_token: Discord 개발자 포털에서 발급한 Bot 토큰(선택). + `Authorization: Bot ` 헤더로 임의 채널에 메시지 전송/조회할 때 쓴다. """ model_config = SettingsConfigDict(env_prefix="DISCORD_", env_file=".env", extra="ignore") webhook_url: str | None = None + bot_token: str | None = None + + +def _bot_headers(token: str) -> dict[str, str]: + """Bot 토큰 인증 헤더. Bearer가 아니라 `Bot ` 스킴을 쓴다. + + 출처: https://discord.com/developers/docs/reference#authentication + """ + return {"Authorization": f"Bot {token}"} def _explain(e: UpstreamError) -> str: payload = e.payload if isinstance(e.payload, dict) else {} - if e.status == 401 or e.status == 404: + code = payload.get("code") + # Discord JSON 에러 코드(공식): + # 50001 Missing access · 50013 You lack permissions to perform that action + # 10003 Unknown channel + # 출처: https://discord.com/developers/docs/topics/opcodes-and-status-codes#json-json-error-codes + if code == 50001: + return "Discord 권한 부족(Missing access): 봇이 해당 채널/길드에 접근할 수 없습니다." + if code == 50013: + return "Discord 권한 부족: 봇에 이 작업 권한이 없습니다(예: View Channel/Send Messages)." + if code == 10003: + return "Discord 채널을 찾을 수 없습니다(Unknown channel). channel_id를 확인하세요." + if e.status == 401: return ( - "Discord webhook URL이 무효이거나 삭제되었습니다. " - "DISCORD_WEBHOOK_URL을 다시 확인하세요." + "Discord 인증 실패(401). Webhook URL 또는 Bot 토큰이 무효입니다. " + "DISCORD_WEBHOOK_URL / DISCORD_BOT_TOKEN을 확인하세요." + ) + if e.status == 403: + return "Discord 접근 거부(403): 봇/웹후크에 해당 작업 권한이 없습니다." + if e.status == 404: + return ( + "Discord 리소스를 찾을 수 없습니다(404). " + "Webhook URL이 삭제되었거나 message_id/channel_id가 잘못되었을 수 있습니다." ) if e.status == 429: retry = payload.get("retry_after") @@ -43,6 +73,8 @@ def _explain(e: UpstreamError) -> str: def register(mcp: FastMCP) -> None: """이 서비스의 도구를 서버에 등록한다.""" + # ─── (A) Webhook 경로 ─────────────────────────────────── + @mcp.tool async def discord_send_message( content: str, @@ -85,3 +117,201 @@ async def discord_send_message( msg = d.MessageResult.model_validate(raw) return f"전송 완료 (message id: {msg.id})" if msg.id else "전송 완료" + + @mcp.tool + async def discord_send_embed( + title: str | None = None, + description: str | None = None, + url: str | None = None, + color: int | None = None, + footer: str | None = None, + ) -> str: + """Discord 채널에 Webhook으로 리치 임베드(카드) 1개를 전송한다. + + Args: + title: 임베드 제목. + description: 임베드 본문 설명. + url: 제목에 걸리는 하이퍼링크 URL. + color: 임베드 좌측 띠 색상(RGB 정수, 예: 빨강=0xFF0000=16711680). + footer: 임베드 하단 푸터 텍스트. + + 주의: title/description/url/color/footer 중 최소 하나는 지정해야 한다. + """ + s = DiscordSettings() + if not s.webhook_url: + return "설정 오류: DISCORD_WEBHOOK_URL 환경변수가 비어 있습니다." + + if not any(v is not None for v in (title, description, url, color, footer)): + return ( + "입력 오류: 임베드에 표시할 필드를 최소 하나" + "(title/description/url/color/footer) 지정하세요." + ) + + try: + embed = d.Embed( + title=title, + description=description, + url=url, + color=color, + footer=d.EmbedFooter(text=footer) if footer else None, + ) + req = d.ExecuteWebhookRequest(embeds=[embed]) + except ValidationError as e: + return f"입력 오류: {e.errors()[0]['msg']}" + + # wait=true로 생성된 Message를 받아 message id를 회신한다(편집/삭제에 필요). + # 출처: https://discord.com/developers/docs/resources/webhook#execute-webhook + try: + raw = await post_json( + f"{s.webhook_url}?{d.WAIT_PARAM}=true", + json=req.model_dump(exclude_none=True), + ) + except UpstreamError as e: + return _explain(e) + + msg = d.MessageResult.model_validate(raw) + return f"임베드 전송 완료 (message id: {msg.id})" if msg.id else "임베드 전송 완료" + + @mcp.tool + async def discord_edit_message( + message_id: str, + content: str | None = None, + ) -> str: + """Webhook이 보낸 기존 메시지를 편집한다(본문 교체). + + PATCH /webhooks/{id}/{token}/messages/{message_id} 를 호출한다. + 편집은 동일 Webhook이 보낸 메시지에만 가능하다. + + Args: + message_id: 편집할 메시지 id. (전송 시 회신된 message id) + content: 새 본문. 최대 2000자. + """ + s = DiscordSettings() + if not s.webhook_url: + return "설정 오류: DISCORD_WEBHOOK_URL 환경변수가 비어 있습니다." + if content is None: + return "입력 오류: 편집할 content를 지정하세요." + + try: + req = d.EditWebhookMessageRequest(content=content) + except ValidationError as e: + return f"입력 오류: {e.errors()[0]['msg']}" + + # Webhook URL 뒤에 /messages/{message_id}를 덧붙여 경로를 파생한다. + # 출처: https://discord.com/developers/docs/resources/webhook#edit-webhook-message + url = s.webhook_url.rstrip("/") + d.WEBHOOK_MESSAGE_SUFFIX.format(message_id=message_id) + try: + raw = await patch_json(url, json=req.model_dump(exclude_none=True)) + except UpstreamError as e: + return _explain(e) + + msg = d.MessageResult.model_validate(raw) + return f"편집 완료 (message id: {msg.id})" if msg.id else "편집 완료" + + @mcp.tool + async def discord_delete_message(message_id: str) -> str: + """Webhook이 보낸 기존 메시지를 삭제한다. + + DELETE /webhooks/{id}/{token}/messages/{message_id} 를 호출한다(성공 시 204 No Content). + + Args: + message_id: 삭제할 메시지 id. + """ + s = DiscordSettings() + if not s.webhook_url: + return "설정 오류: DISCORD_WEBHOOK_URL 환경변수가 비어 있습니다." + + # 출처: https://discord.com/developers/docs/resources/webhook#delete-webhook-message + url = s.webhook_url.rstrip("/") + d.WEBHOOK_MESSAGE_SUFFIX.format(message_id=message_id) + try: + await delete_json(url) + except UpstreamError as e: + return _explain(e) + + return f"삭제 완료 (message id: {message_id})" + + # ─── (B) Bot 토큰 경로 ────────────────────────────────── + + @mcp.tool + async def discord_create_message(channel_id: str, content: str) -> str: + """Bot 토큰으로 임의 채널에 메시지를 전송한다. + + POST /channels/{channel_id}/messages, 헤더 `Authorization: Bot `. + DISCORD_BOT_TOKEN이 설정돼 있어야 한다. + + Args: + channel_id: 대상 채널 id(snowflake). + content: 보낼 본문. 최대 2000자. + """ + s = DiscordSettings() + if not s.bot_token: + return ( + "설정 오류: DISCORD_BOT_TOKEN 환경변수가 비어 있습니다. " + "Discord 개발자 포털에서 봇 토큰을 발급해 .env에 DISCORD_BOT_TOKEN으로 넣으세요." + ) + + try: + req = d.CreateMessageRequest(content=content) + except ValidationError as e: + return f"입력 오류: {e.errors()[0]['msg']}" + + # 출처: https://discord.com/developers/docs/resources/message#create-message + url = d.API_BASE_URL + d.CHANNEL_MESSAGES_ROUTE.format(channel_id=channel_id) + try: + raw = await post_json( + url, + headers=_bot_headers(s.bot_token), + json=req.model_dump(exclude_none=True), + ) + except UpstreamError as e: + return _explain(e) + + msg = d.MessageResult.model_validate(raw) + return f"전송 완료 (message id: {msg.id})" if msg.id else "전송 완료" + + @mcp.tool + async def discord_list_messages( + channel_id: str, + limit: int = d.MESSAGES_LIMIT_DEFAULT, + ) -> str: + """Bot 토큰으로 채널의 최근 메시지를 조회한다. + + GET /channels/{channel_id}/messages?limit=N, 헤더 `Authorization: Bot `. + DISCORD_BOT_TOKEN이 설정돼 있어야 한다. + + Args: + channel_id: 대상 채널 id(snowflake). + limit: 가져올 개수(1–100, 기본 50). + """ + s = DiscordSettings() + if not s.bot_token: + return ( + "설정 오류: DISCORD_BOT_TOKEN 환경변수가 비어 있습니다. " + "Discord 개발자 포털에서 봇 토큰을 발급해 .env에 DISCORD_BOT_TOKEN으로 넣으세요." + ) + if not (d.MESSAGES_LIMIT_MIN <= limit <= d.MESSAGES_LIMIT_MAX): + return f"입력 오류: limit은 {d.MESSAGES_LIMIT_MIN}–{d.MESSAGES_LIMIT_MAX} 범위여야 합니다." + + # 출처: https://discord.com/developers/docs/resources/message#get-channel-messages + url = d.API_BASE_URL + d.CHANNEL_MESSAGES_ROUTE.format(channel_id=channel_id) + try: + raw = await get_json( + url, + headers=_bot_headers(s.bot_token), + params={"limit": limit}, + ) + except UpstreamError as e: + return _explain(e) + + # 응답은 Message 배열(get_json은 -> dict 힌트지만 JSON 배열이면 list를 그대로 반환). + if not isinstance(raw, list): + return f"예상치 못한 응답: {raw}" + msgs = [d.MessageResult.model_validate(m) for m in raw if isinstance(m, dict)] + if not msgs: + return "메시지가 없습니다." + lines = [] + for m in msgs: + who = m.author.username if m.author and m.author.username else "?" + body = (m.content or "").replace("\n", " ") + lines.append(f"- [{m.id}] {who}: {body}") + return f"{len(msgs)}개 메시지:\n" + "\n".join(lines) diff --git a/arcsolve/services/line/README.md b/arcsolve/services/line/README.md index 5363e05..015f4c3 100644 --- a/arcsolve/services/line/README.md +++ b/arcsolve/services/line/README.md @@ -1,21 +1,31 @@ # LINE 서비스 -LINE Messaging API의 **push 메시지(텍스트)** 래퍼. +LINE Messaging API의 **텍스트 메시지 전송(push/reply/multicast/broadcast) + 프로필 조회** 래퍼. ## 계약 출처 (공식 문서) - Messaging API 레퍼런스: https://developers.line.biz/en/reference/messaging-api/ - Send push message: https://developers.line.biz/en/reference/messaging-api/#send-push-message +- Send reply message: https://developers.line.biz/en/reference/messaging-api/#send-reply-message +- Send multicast message: https://developers.line.biz/en/reference/messaging-api/#send-multicast-message +- Send broadcast message: https://developers.line.biz/en/reference/messaging-api/#send-broadcast-message +- Get profile: https://developers.line.biz/en/reference/messaging-api/#get-profile - Text message object: https://developers.line.biz/en/reference/messaging-api/#text-message - 채널 액세스 토큰: https://developers.line.biz/en/docs/messaging-api/channel-access-tokens/ > 계약 본체는 [`contract.py`](contract.py)에 코드로 박제되어 있다(엔드포인트·요청/응답 모델·제약). ## 엔드포인트 -| 종류 | METHOD · PATH | -|------|------| -| push 메시지 | `POST /v2/bot/message/push` | +| 종류 | METHOD · PATH | 성공 응답 | +|------|------|------| +| push 메시지 | `POST /v2/bot/message/push` | `{sentMessages:[…]}` | +| reply 메시지 | `POST /v2/bot/message/reply` | `{sentMessages:[…]}` | +| multicast 메시지 | `POST /v2/bot/message/multicast` | 빈 객체 `{}` | +| broadcast 메시지 | `POST /v2/bot/message/broadcast` | 빈 객체 `{}` | +| 프로필 조회 | `GET /v2/bot/profile/{userId}` | `Profile` 객체 | -Base: `https://api.line.me` · 인증: `Authorization: Bearer {channel access token}` · Content-Type: `application/json` +Base: `https://api.line.me` · 인증: `Authorization: Bearer {channel access token}` · Content-Type: `application/json`(POST) + +> **응답 형태는 엔드포인트별로 공식 확인**: push/reply는 `sentMessages[]`, multicast/broadcast는 빈 객체 `{}`를 반환한다(문서 "Returns status code 200 and an empty JSON object"). ## 셋업 1. [LINE Developers 콘솔](https://developers.line.biz)에서 **Messaging API 채널** 생성 @@ -30,20 +40,36 @@ Base: `https://api.line.me` · 인증: `Authorization: Bearer {channel access to | 도구 | 설명 | |------|------| | `line_send_text(text, to?)` | 텍스트(≤5000자) push 1건. `to` 미지정 시 `LINE_TO` 사용 | +| `line_reply_text(reply_token, text)` | webhook의 reply_token으로 텍스트 회신. 토큰은 1회용·곧 만료 | +| `line_multicast_text(to, text)` | userId 배열(1~**500**개)에 동일 텍스트 전송. groupId/roomId 불가 | +| `line_broadcast_text(text)` | 모든 친구에게 텍스트 전송 | +| `line_get_profile(user_id)` | userId의 프로필(displayName/userId/+선택 필드) 조회 | ## 범위 / 제약 -- MVP는 **텍스트 push 1건만**. 멀티캐스트/브로드캐스트/리플라이, 그 외 메시지 타입(이미지·스티커 등)은 v2로 분리. +- 텍스트 메시지의 **push/reply/multicast/broadcast** + **프로필 조회**까지 지원. 그 외 메시지 타입(이미지·스티커 등)·narrowcast·그룹 멤버 프로필 등은 추후 동일 패턴으로 확장. +- `reply_token`은 webhook 이벤트에서만 얻을 수 있다(우리 webhook 서버는 범위 밖 — 호출자가 전달). + +### 요청/텍스트 필드 (공식 계약) +| 필드 | 적용 | 필수 | 비고 | +|------|------|------|------| +| `to` | push | 필수 | 수신자 `userId`/`groupId`/`roomId` | +| `to` | multicast | 필수 | `userId` 배열, 최대 **500개**(groupId/roomId 불가) | +| `replyToken` | reply | 필수 | webhook 이벤트의 1회용 토큰 | +| `messages` | push/reply/multicast/broadcast | 필수 | 메시지 배열, 최대 **5개** | +| `notificationDisabled` | 전부(broadcast 포함) | 선택 | `true`면 푸시 알림 미수신 | +| `messages[].type` | 전부 | 필수 | `"text"` 고정 | +| `messages[].text` | 전부 | 필수 | 최대 **5000자**(UTF-16 코드 유닛 기준) | -### push 요청/텍스트 필드 (공식 계약) -| 필드 | 필수 | 비고 | +### Profile 응답 필드 (공식 계약, Get profile) +| 필드 | 항상 포함 | 비고 | |------|------|------| -| `to` | 필수 | 수신자 `userId`/`groupId`/`roomId` | -| `messages` | 필수 | 메시지 배열, 최대 **5개** | -| `notificationDisabled` | 선택 | `true`면 푸시 알림 미수신 | -| `messages[].type` | 필수 | `"text"` 고정 | -| `messages[].text` | 필수 | 최대 **5000자**(UTF-16 코드 유닛 기준) | +| `displayName` | 예 | 표시 이름 | +| `userId` | 예 | 사용자 ID | +| `pictureUrl` | 아니오 | 프로필 이미지 URL(없으면 미포함) | +| `statusMessage` | 아니오 | 상태 메시지(없으면 미포함) | +| `language` | 아니오 | BCP 47 언어 태그(개인정보 미동의 시 미포함) | ## 확장 포인트 - 다른 메시지 타입(image / sticker / location / template 등): `contract.py`에 모델 추가 → `tools.py`에 도구 추가. -- 멀티캐스트(`/v2/bot/message/multicast`) / 브로드캐스트(`/v2/bot/message/broadcast`): 동일 패턴으로 엔드포인트·도구 추가. +- narrowcast(`/v2/bot/message/narrowcast`), 그룹/멀티퍼슨 멤버 프로필(`/v2/bot/group|room/.../member/...`) 등: 동일 패턴으로 엔드포인트·도구 추가. - `customAggregationUnits`, `emojis`, `quoteToken` 등 선택 필드 노출. diff --git a/arcsolve/services/line/__init__.py b/arcsolve/services/line/__init__.py index fe078fa..71cff6b 100644 --- a/arcsolve/services/line/__init__.py +++ b/arcsolve/services/line/__init__.py @@ -5,6 +5,6 @@ name="line", register=register, docs_url="https://developers.line.biz/en/reference/messaging-api/", - summary="LINE Messaging API — 텍스트 push 메시지 전송", + summary="LINE Messaging API — 텍스트 메시지 전송(push/reply/multicast/broadcast) + 프로필 조회", # 채널 액세스 토큰(Bearer) 방식 — 인터랙티브 OAuth 아님 → make_auth_client 없음. ) diff --git a/arcsolve/services/line/contract.py b/arcsolve/services/line/contract.py index d7a427f..c79d54d 100644 --- a/arcsolve/services/line/contract.py +++ b/arcsolve/services/line/contract.py @@ -6,6 +6,10 @@ 출처(공식 문서): - Messaging API 레퍼런스 : https://developers.line.biz/en/reference/messaging-api/ - Send push message : https://developers.line.biz/en/reference/messaging-api/#send-push-message + - Send reply message : https://developers.line.biz/en/reference/messaging-api/#send-reply-message + - Send multicast message : https://developers.line.biz/en/reference/messaging-api/#send-multicast-message + - Send broadcast message : https://developers.line.biz/en/reference/messaging-api/#send-broadcast-message + - Get profile : https://developers.line.biz/en/reference/messaging-api/#get-profile - Text message object : https://developers.line.biz/en/reference/messaging-api/#text-message - Channel access token : https://developers.line.biz/en/docs/messaging-api/channel-access-tokens/ - 텍스트 문자수 카운팅 : https://developers.line.biz/en/docs/messaging-api/text-character-count/ @@ -26,10 +30,21 @@ # 출처: https://developers.line.biz/en/reference/messaging-api/#send-push-message BASE_URL = "https://api.line.me" PUSH_MESSAGE = "/v2/bot/message/push" # POST · Content-Type: application/json +# 출처: https://developers.line.biz/en/reference/messaging-api/#send-reply-message +REPLY_MESSAGE = "/v2/bot/message/reply" # POST · Content-Type: application/json +# 출처: https://developers.line.biz/en/reference/messaging-api/#send-multicast-message +MULTICAST_MESSAGE = "/v2/bot/message/multicast" # POST · Content-Type: application/json +# 출처: https://developers.line.biz/en/reference/messaging-api/#send-broadcast-message +BROADCAST_MESSAGE = "/v2/bot/message/broadcast" # POST · Content-Type: application/json +# 출처: https://developers.line.biz/en/reference/messaging-api/#get-profile +# GET — 경로에 userId를 끼워넣는다: PROFILE.format(user_id=...) +PROFILE = "/v2/bot/profile/{user_id}" # 문서 명시 제약(출처: 위 push message / text message 섹션) MAX_MESSAGES = 5 # messages 배열은 최대 5개 MAX_TEXT_LENGTH = 5000 # text 필드 최대 5000자(UTF-16 코드 유닛 기준) +# multicast `to` 배열 상한(출처: send-multicast-message 요청 본문 "Max: 500 user IDs") +MAX_MULTICAST_RECIPIENTS = 500 class TextMessage(BaseModel): @@ -103,6 +118,75 @@ class PushResult(BaseModel): sentMessages: list[SentMessage] = Field(default_factory=list) # noqa: N815 (공식 필드명) +class ReplyRequest(BaseModel): + """reply message 요청 본문. + + 공식 필드: replyToken(필수, webhook 이벤트에서 받은 일회용 토큰) · + messages(필수, 최대 5개) · notificationDisabled(선택). replyToken은 우리 범위 밖 + webhook 서버가 발급/전달한다(여기서는 그대로 받아 본문에 싣는다). + 출처: https://developers.line.biz/en/reference/messaging-api/#send-reply-message + """ + + replyToken: str = Field(min_length=1) # noqa: N815 (공식 카멜케이스 필드명) + messages: list[TextMessage] = Field(min_length=1, max_length=MAX_MESSAGES) + notificationDisabled: bool | None = None # noqa: N815 (공식 카멜케이스 필드명) + + +class MulticastRequest(BaseModel): + """multicast message 요청 본문. + + 공식 필드: to(필수, userId 배열 — 최대 500개, groupId/roomId 불가) · + messages(필수, 최대 5개) · notificationDisabled(선택). customAggregationUnits는 + MVP에서 노출하지 않는다. + 출처: https://developers.line.biz/en/reference/messaging-api/#send-multicast-message + """ + + to: list[str] = Field(min_length=1, max_length=MAX_MULTICAST_RECIPIENTS) + messages: list[TextMessage] = Field(min_length=1, max_length=MAX_MESSAGES) + notificationDisabled: bool | None = None # noqa: N815 (공식 카멜케이스 필드명) + + +class BroadcastRequest(BaseModel): + """broadcast message 요청 본문. + + 공식 필드: messages(필수, 최대 5개) · notificationDisabled(선택). 수신자는 + LINE 공식 계정의 모든 친구(별도 to 없음). + 출처: https://developers.line.biz/en/reference/messaging-api/#send-broadcast-message + """ + + messages: list[TextMessage] = Field(min_length=1, max_length=MAX_MESSAGES) + notificationDisabled: bool | None = None # noqa: N815 (공식 카멜케이스 필드명) + + +class EmptyResult(BaseModel): + """multicast/broadcast 응답. + + 공식: 성공 시 HTTP 200 + **빈 JSON 객체 `{}`**(push/reply의 sentMessages와 다름). + 출처(multicast): https://developers.line.biz/en/reference/messaging-api/#send-multicast-message + 출처(broadcast): https://developers.line.biz/en/reference/messaging-api/#send-broadcast-message + """ + + model_config = {"extra": "ignore"} + + +class Profile(BaseModel): + """Get profile 응답. + + 공식 필드: displayName(항상) · userId(항상) · pictureUrl(선택, 프로필 이미지 없으면 미포함) · + statusMessage(선택, 상태 메시지 없으면 미포함) · language(선택, BCP 47 태그 — 사용자가 + 개인정보 처리방침에 미동의 시 미포함). 문서상 "Not always included"로 표기된 셋만 Optional. + 출처: https://developers.line.biz/en/reference/messaging-api/#get-profile + """ + + model_config = {"extra": "ignore"} + + displayName: str # noqa: N815 (공식 카멜케이스 필드명) + userId: str # noqa: N815 (공식 카멜케이스 필드명) + pictureUrl: str | None = None # noqa: N815 (공식 카멜케이스 필드명, "Not always included") + statusMessage: str | None = None # noqa: N815 (공식 카멜케이스 필드명, "Not always included") + language: str | None = None # ("Not always included") + + class ErrorResponse(BaseModel): """Messaging API 표준 에러 응답. diff --git a/arcsolve/services/line/tools.py b/arcsolve/services/line/tools.py index 3c297d1..da1a20d 100644 --- a/arcsolve/services/line/tools.py +++ b/arcsolve/services/line/tools.py @@ -10,7 +10,7 @@ from pydantic import ValidationError from pydantic_settings import BaseSettings, SettingsConfigDict -from arcsolve.http import UpstreamError, bearer, post_json +from arcsolve.http import UpstreamError, bearer, get_json, post_json from arcsolve.services.line import contract as l @@ -35,6 +35,8 @@ def _explain(e: UpstreamError) -> str: return "LINE 채널 액세스 토큰이 없거나 무효입니다. LINE_CHANNEL_ACCESS_TOKEN을 확인하세요." if e.status == 403: return f"권한 없음(403): 채널 설정/플랜을 확인하세요. {detail}" + if e.status == 404: + return f"프로필을 찾을 수 없음(404): userId가 없거나 친구가 아니거나 동의하지 않았습니다. {detail}" if e.status == 409: return f"중복 요청(409): 동일 retry-key가 이미 처리되었을 수 있습니다. {detail}" if e.status == 429: @@ -79,3 +81,132 @@ async def line_send_text(text: str, to: str | None = None) -> str: result = l.PushResult.model_validate(raw if isinstance(raw, dict) else {}) sent_id = result.sentMessages[0].id if result.sentMessages else None return f"전송 완료 (id={sent_id})" if sent_id else "전송 완료" + + @mcp.tool + async def line_reply_text(reply_token: str, text: str) -> str: + """LINE Messaging API reply로 텍스트 메시지 1건을 회신한다. + + webhook 이벤트에서 받은 일회용 reply_token이 필요하다(우리 webhook 서버는 범위 밖 + 이므로 호출자가 토큰을 전달해야 한다). reply token은 1회용이며 발급 후 곧 만료된다. + + Args: + reply_token: webhook 이벤트의 replyToken. + text: 보낼 본문. 최대 5000자(UTF-16 코드 유닛 기준). + """ + settings = LineSettings() + token = settings.channel_access_token + if not token: + return "설정 오류: LINE_CHANNEL_ACCESS_TOKEN 환경변수가 필요합니다." + + try: + req = l.ReplyRequest(replyToken=reply_token, messages=[l.TextMessage(text=text)]) + except ValidationError as e: + return f"입력 오류: {e.errors()[0]['msg']}" + + try: + raw = await post_json( + l.BASE_URL + l.REPLY_MESSAGE, + headers=bearer(token), + json=req.model_dump(exclude_none=True), + ) + except UpstreamError as e: + return _explain(e) + + # reply 응답은 push와 동일하게 sentMessages[]를 반환한다. + result = l.PushResult.model_validate(raw if isinstance(raw, dict) else {}) + sent_id = result.sentMessages[0].id if result.sentMessages else None + return f"회신 완료 (id={sent_id})" if sent_id else "회신 완료" + + @mcp.tool + async def line_multicast_text(to: list[str], text: str) -> str: + """LINE Messaging API multicast로 동일 텍스트를 여러 userId에게 전송한다. + + to는 userId 배열만 허용한다(groupId/roomId 불가). 최대 500개. + + Args: + to: 수신자 userId 목록(1~500개). + text: 보낼 본문. 최대 5000자(UTF-16 코드 유닛 기준). + """ + settings = LineSettings() + token = settings.channel_access_token + if not token: + return "설정 오류: LINE_CHANNEL_ACCESS_TOKEN 환경변수가 필요합니다." + + try: + req = l.MulticastRequest(to=to, messages=[l.TextMessage(text=text)]) + except ValidationError as e: + return f"입력 오류: {e.errors()[0]['msg']}" + + try: + await post_json( + l.BASE_URL + l.MULTICAST_MESSAGE, + headers=bearer(token), + json=req.model_dump(exclude_none=True), + ) + except UpstreamError as e: + return _explain(e) + + # multicast 성공 응답은 빈 객체 {} — 메시지 ID 없음. + return f"멀티캐스트 전송 완료 ({len(to)}명)" + + @mcp.tool + async def line_broadcast_text(text: str) -> str: + """LINE Messaging API broadcast로 모든 친구에게 텍스트 1건을 전송한다. + + Args: + text: 보낼 본문. 최대 5000자(UTF-16 코드 유닛 기준). + """ + settings = LineSettings() + token = settings.channel_access_token + if not token: + return "설정 오류: LINE_CHANNEL_ACCESS_TOKEN 환경변수가 필요합니다." + + try: + req = l.BroadcastRequest(messages=[l.TextMessage(text=text)]) + except ValidationError as e: + return f"입력 오류: {e.errors()[0]['msg']}" + + try: + await post_json( + l.BASE_URL + l.BROADCAST_MESSAGE, + headers=bearer(token), + json=req.model_dump(exclude_none=True), + ) + except UpstreamError as e: + return _explain(e) + + # broadcast 성공 응답은 빈 객체 {} — 메시지 ID 없음. + return "브로드캐스트 전송 완료" + + @mcp.tool + async def line_get_profile(user_id: str) -> str: + """LINE Messaging API로 사용자 프로필 정보를 조회한다. + + Args: + user_id: 조회할 사용자 userId(webhook 이벤트에서 얻은 값). + """ + settings = LineSettings() + token = settings.channel_access_token + if not token: + return "설정 오류: LINE_CHANNEL_ACCESS_TOKEN 환경변수가 필요합니다." + + if not user_id: + return "입력 오류: user_id 를 지정하세요." + + try: + raw = await get_json( + l.BASE_URL + l.PROFILE.format(user_id=user_id), + headers=bearer(token), + ) + except UpstreamError as e: + return _explain(e) + + profile = l.Profile.model_validate(raw if isinstance(raw, dict) else {}) + parts = [f"이름: {profile.displayName}", f"userId: {profile.userId}"] + if profile.statusMessage: + parts.append(f"상태메시지: {profile.statusMessage}") + if profile.language: + parts.append(f"언어: {profile.language}") + if profile.pictureUrl: + parts.append(f"프로필이미지: {profile.pictureUrl}") + return " · ".join(parts) diff --git a/arcsolve/services/telegram/README.md b/arcsolve/services/telegram/README.md index ec8d608..3a2e332 100644 --- a/arcsolve/services/telegram/README.md +++ b/arcsolve/services/telegram/README.md @@ -1,19 +1,32 @@ # Telegram 서비스 -Telegram Bot API의 **sendMessage** 래퍼. 봇으로 텍스트 메시지를 전송한다("나에게 보내기" 결). +Telegram Bot API 래퍼. 봇으로 텍스트/사진/문서를 전송하고("나에게 보내기" 결), +보낸 메시지를 편집·삭제하며, 봇 신원(헬스체크)을 확인한다. ## 계약 출처 (공식 문서) - Bot API 레퍼런스: https://core.telegram.org/bots/api - sendMessage 메서드: https://core.telegram.org/bots/api#sendmessage +- getMe 메서드: https://core.telegram.org/bots/api#getme +- sendPhoto 메서드: https://core.telegram.org/bots/api#sendphoto +- sendDocument 메서드: https://core.telegram.org/bots/api#senddocument +- editMessageText 메서드: https://core.telegram.org/bots/api#editmessagetext +- deleteMessage 메서드: https://core.telegram.org/bots/api#deletemessage - 요청/응답 포맷(Making requests): https://core.telegram.org/bots/api#making-requests - LinkPreviewOptions 오브젝트: https://core.telegram.org/bots/api#linkpreviewoptions +- User 오브젝트: https://core.telegram.org/bots/api#user +- Message 오브젝트: https://core.telegram.org/bots/api#message > 계약 본체는 [`contract.py`](contract.py)에 코드로 박제되어 있다(엔드포인트·요청/응답 모델). ## 엔드포인트 | 종류 | METHOD · PATH | |------|------| +| 봇 신원/헬스체크 | `GET /bot/getMe` | | 메시지 전송 | `POST /bot/sendMessage` | +| 사진 전송 | `POST /bot/sendPhoto` | +| 문서 전송 | `POST /bot/sendDocument` | +| 메시지 편집 | `POST /bot/editMessageText` | +| 메시지 삭제 | `POST /bot/deleteMessage` | Base: `https://api.telegram.org` · 인증: **봇 토큰을 URL 경로에** (`/bot/...`, Bearer 아님) · 스코프: 없음 @@ -29,13 +42,23 @@ Base: `https://api.telegram.org` · 인증: **봇 토큰을 URL 경로에** (`/b ## 도구 | 도구 | 설명 | |------|------| +| `telegram_get_me()` | 봇 신원/토큰 유효성 확인(헬스체크). 파라미터 없음 | | `telegram_send_message(text, chat_id?, parse_mode?, disable_link_preview?, disable_notification?)` | 텍스트(1-4096자) 전송. `chat_id` 미지정 시 `TELEGRAM_CHAT_ID` 사용 | +| `telegram_send_photo(photo, caption?, chat_id?, parse_mode?)` | 사진 전송. `photo`는 **URL 또는 file_id 문자열만**. `caption` 0-1024자. `chat_id` 미지정 시 `TELEGRAM_CHAT_ID` 사용 | +| `telegram_send_document(document, caption?, chat_id?, parse_mode?)` | 문서 전송. `document`는 **URL 또는 file_id 문자열만**. `caption` 0-1024자. `chat_id` 미지정 시 `TELEGRAM_CHAT_ID` 사용 | +| `telegram_edit_message_text(chat_id, message_id, text, parse_mode?)` | 봇이 보낸 메시지의 텍스트(1-4096자) 편집 | +| `telegram_delete_message(chat_id, message_id)` | 메시지 삭제(성공 시 True) | ## 범위 / 제약 -- MVP는 **텍스트 전송(sendMessage)만**. 사진/문서 등 미디어, 인라인 키보드(`reply_markup`), - 답장(`reply_parameters`), 엔티티(`entities`) 등은 v2로 분리한다. -- `text`는 공식 제약대로 **1-4096자**(엔티티 파싱 후 기준). +- 텍스트/사진/문서 전송, 메시지 편집·삭제, 봇 헬스체크를 노출한다. 인라인 키보드(`reply_markup`), + 답장(`reply_parameters`), 엔티티(`entities`)는 v2로 분리한다. +- `text`는 공식 제약대로 **1-4096자**(엔티티 파싱 후 기준). `caption`은 **0-1024자**. - `parse_mode`는 `"MarkdownV2"` 또는 `"HTML"`만 노출(legacy `"Markdown"` 제외). +- **사진/문서 전송은 `photo`/`document`가 URL 또는 file_id 문자열일 때만 지원**한다. + 로컬 파일 업로드(multipart/form-data)는 **코어에 multipart 동사가 없어 미구현**이며, + 코어 multipart 동사가 추가된 뒤 별도로 지원할 예정이다. +- `editMessageText`/`deleteMessage`는 봇 자신이 보낸(또는 접근 가능한) 메시지에만 동작한다. + 인라인 메시지(`inline_message_id`) 경로는 범위 밖이다. ### sendMessage 필드 (공식 계약, MVP 노출분) | 필드 | 필수 | 비고 | @@ -48,7 +71,24 @@ Base: `https://api.telegram.org` · 인증: **봇 토큰을 URL 경로에** (`/b | `disable_notification` | 선택 | 조용히 전송 | | `protect_content` | 선택 | 전달/저장 방지 (contract.py에만, 도구 미노출) | +### sendPhoto / sendDocument 필드 (공식 계약, 노출분) +| 필드 | 필수 | 비고 | +|------|------|------| +| `chat_id` | 필수 | 정수 ID 또는 `"@channelusername"` | +| `photo` / `document` | 필수 | **URL 또는 file_id 문자열만** (로컬 업로드 미지원) | +| `caption` | 선택 | 0-1024자 | +| `parse_mode` | 선택 | `MarkdownV2` / `HTML` | + +### editMessageText / deleteMessage 필드 (공식 계약, 노출분) +| 필드 | 필수 | 비고 | +|------|------|------| +| `chat_id` | 필수 | 정수 ID 또는 `"@channelusername"` | +| `message_id` | 필수 | 편집/삭제할 메시지 ID | +| `text` | 필수(편집) | 1-4096자 | +| `parse_mode` | 선택(편집) | `MarkdownV2` / `HTML` | + ## 확장 포인트 -- 미디어 전송: `sendPhoto` / `sendDocument` 메서드 + (multipart) 코어 동사 필요 시 보강. +- 로컬 파일 업로드: `sendPhoto`/`sendDocument`의 multipart/form-data 경로 — 코어 multipart 동사 추가 후. - 인라인 버튼: `reply_markup`(InlineKeyboardMarkup) 모델 추가. - 답장/엔티티: `reply_parameters`, `entities` 모델 추가. +- 인라인 메시지 편집: `editMessageText`의 `inline_message_id` 경로. diff --git a/arcsolve/services/telegram/__init__.py b/arcsolve/services/telegram/__init__.py index c764320..ffe5faf 100644 --- a/arcsolve/services/telegram/__init__.py +++ b/arcsolve/services/telegram/__init__.py @@ -5,6 +5,6 @@ name="telegram", register=register, docs_url="https://core.telegram.org/bots/api", - summary="Telegram Bot API — sendMessage로 텍스트 전송", + summary="Telegram Bot API — 텍스트/사진/문서 전송, 메시지 편집·삭제, getMe 헬스체크", # OAuth 아님: 봇 토큰을 URL 경로에 넣는 방식이라 make_auth_client 없음. ) diff --git a/arcsolve/services/telegram/contract.py b/arcsolve/services/telegram/contract.py index 7231c0b..f2c1809 100644 --- a/arcsolve/services/telegram/contract.py +++ b/arcsolve/services/telegram/contract.py @@ -6,8 +6,14 @@ 출처(공식 문서): - Bot API 레퍼런스 : https://core.telegram.org/bots/api - sendMessage 메서드 : https://core.telegram.org/bots/api#sendmessage + - getMe 메서드 : https://core.telegram.org/bots/api#getme + - sendPhoto 메서드 : https://core.telegram.org/bots/api#sendphoto + - sendDocument 메서드 : https://core.telegram.org/bots/api#senddocument + - editMessageText 메서드 : https://core.telegram.org/bots/api#editmessagetext + - deleteMessage 메서드 : https://core.telegram.org/bots/api#deletemessage - 요청/응답 포맷(Making requests): https://core.telegram.org/bots/api#making-requests - LinkPreviewOptions 오브젝트 : https://core.telegram.org/bots/api#linkpreviewoptions + - User 오브젝트 : https://core.telegram.org/bots/api#user - Message 오브젝트 : https://core.telegram.org/bots/api#message - LinkPreviewOptions 도입(7.0): https://core.telegram.org/bots/api-changelog """ @@ -16,7 +22,7 @@ from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator # ─── 인증 / 엔드포인트 ─────────────────────────────────────── # 공식: "All queries to the Telegram Bot API must be served over HTTPS and need to @@ -34,12 +40,22 @@ def method_path(token: str, method: str) -> str: return f"/bot{token}/{method}" -# 메서드 이름 상수 (출처: https://core.telegram.org/bots/api#sendmessage) -SEND_MESSAGE = "sendMessage" +# 메서드 이름 상수 +SEND_MESSAGE = "sendMessage" # https://core.telegram.org/bots/api#sendmessage +GET_ME = "getMe" # https://core.telegram.org/bots/api#getme +SEND_PHOTO = "sendPhoto" # https://core.telegram.org/bots/api#sendphoto +SEND_DOCUMENT = "sendDocument" # https://core.telegram.org/bots/api#senddocument +EDIT_MESSAGE_TEXT = "editMessageText" # https://core.telegram.org/bots/api#editmessagetext +DELETE_MESSAGE = "deleteMessage" # https://core.telegram.org/bots/api#deletemessage # parse_mode 허용값 (출처: https://core.telegram.org/bots/api#formatting-options) PARSE_MODES = ("MarkdownV2", "HTML") # "Markdown"은 legacy로도 존재하나 MVP는 권장 2종만 노출 +# caption 길이 제약 (출처: sendPhoto/sendDocument 공식 표 "0-1024 characters") +CAPTION_MAX_LENGTH = 1024 +# text 길이 제약 (출처: sendMessage/editMessageText 공식 표 "1-4096 characters") +TEXT_MAX_LENGTH = 4096 + class LinkPreviewOptions(BaseModel): """발신 메시지의 링크 미리보기 옵션. @@ -66,7 +82,7 @@ class SendMessage(BaseModel): # 필수: 대상 채팅 ID(정수) 또는 채널 username("@channelusername") 문자열. chat_id: int | str # 필수: 본문. "1-4096 characters after entities parsing." - text: str = Field(min_length=1, max_length=4096) + text: str = Field(min_length=1, max_length=TEXT_MAX_LENGTH) # 선택: 메시지 엔티티 파싱 모드("MarkdownV2" | "HTML" 등). parse_mode: Literal["MarkdownV2", "HTML"] | None = None # 선택: 포럼 슈퍼그룹의 특정 토픽 ID(Bot API 6.x에서 추가). @@ -84,15 +100,111 @@ class SendMessage(BaseModel): # (entities, reply_parameters, reply_markup, message_effect_id 등)는 MVP 범위 밖이라 의도적으로 생략. +class SendPhoto(BaseModel): + """sendPhoto 요청 본문. + + 공식 표의 필수 필드는 chat_id, photo이며 나머지는 선택. + `photo`는 InputFile 또는 String이며, String일 때 **file_id 또는 HTTP URL**을 의미한다 + ("Pass a file_id ... Pass an HTTP URL ... or upload a new photo"). + 이 계약은 JSON(post_json) 전송만 다루므로 photo는 **문자열(URL/file_id)만** 허용한다. + 로컬 파일 업로드(multipart/form-data)는 코어에 multipart 동사가 없어 미지원. + 출처: https://core.telegram.org/bots/api#sendphoto + """ + + chat_id: int | str + # photo: URL 또는 file_id 문자열만(로컬 업로드 미지원 — 코어 multipart 동사 추가 후). + photo: str = Field(min_length=1) + # 선택: 캡션. "0-1024 characters after entities parsing." + caption: str | None = Field(default=None, max_length=CAPTION_MAX_LENGTH) + parse_mode: Literal["MarkdownV2", "HTML"] | None = None + disable_notification: bool | None = None + protect_content: bool | None = None + + # provenance(검증 완료): sendPhoto의 required는 chat_id, photo 둘뿐, caption은 0-1024자. + # (caption_entities/show_caption_above_media/reply_parameters/reply_markup 등은 MVP 범위 밖 생략.) + + +class SendDocument(BaseModel): + """sendDocument 요청 본문. + + 필수 필드는 chat_id, document. `document`는 sendPhoto의 photo와 동일하게 + String일 때 file_id 또는 HTTP URL을 의미하며, 본 계약은 문자열만 허용(로컬 업로드 미지원). + 출처: https://core.telegram.org/bots/api#senddocument + """ + + chat_id: int | str + # document: URL 또는 file_id 문자열만(로컬 업로드 미지원 — 코어 multipart 동사 추가 후). + document: str = Field(min_length=1) + # 선택: 캡션. "0-1024 characters after entities parsing."(sendPhoto와 동일 제약) + caption: str | None = Field(default=None, max_length=CAPTION_MAX_LENGTH) + parse_mode: Literal["MarkdownV2", "HTML"] | None = None + disable_notification: bool | None = None + protect_content: bool | None = None + + # provenance(검증 완료): sendDocument의 required는 chat_id, document, caption은 0-1024자. + + +class EditMessageText(BaseModel): + """editMessageText 요청 본문. + + 공식 필수성(조건부·상호 배타): chat_id+message_id는 inline_message_id가 없을 때 필수이고, + inline_message_id는 chat_id/message_id가 없을 때 필수다. text는 항상 필수("1-4096 characters"). + 성공 시 편집된 Message(또는 인라인 메시지의 경우 True)를 반환한다. + 출처: https://core.telegram.org/bots/api#editmessagetext + """ + + # 조건부 필수: (chat_id+message_id) 또는 inline_message_id 중 정확히 한 경로(아래 validator). + chat_id: int | str | None = None + message_id: int | None = None + inline_message_id: str | None = None + # 필수: 새 본문. "1-4096 characters after entities parsing."(sendMessage와 동일) + text: str = Field(min_length=1, max_length=TEXT_MAX_LENGTH) + parse_mode: Literal["MarkdownV2", "HTML"] | None = None + link_preview_options: LinkPreviewOptions | None = None + + @model_validator(mode="after") + def _require_one_target(self) -> EditMessageText: + """공식의 조건부 필수성을 강제한다: 두 경로는 상호 배타, 정확히 하나만 지정.""" + has_chat = self.chat_id is not None and self.message_id is not None + has_inline = self.inline_message_id is not None + if has_inline and (self.chat_id is not None or self.message_id is not None): + raise ValueError("inline_message_id와 chat_id/message_id는 함께 쓸 수 없습니다.") + if not has_chat and not has_inline: + raise ValueError("chat_id+message_id 또는 inline_message_id 중 하나가 필요합니다.") + return self + + # provenance(검증 완료): chat_id/message_id/inline_message_id의 조건부·상호배타 필수성을 + # 공식대로 모델링(text 1-4096, parse_mode/link_preview_options는 Optional). + # entities/reply_markup은 MVP 범위 밖 의도적 생략. + + +class DeleteMessage(BaseModel): + """deleteMessage 요청 본문. + + 공식: chat_id, message_id 모두 필수. "Returns True on success." + 출처: https://core.telegram.org/bots/api#deletemessage + """ + + chat_id: int | str + message_id: int + + class User(BaseModel): - """Message.from 등에 쓰이는 사용자 오브젝트(부분). + """getMe 결과 및 Message.from 등에 쓰이는 사용자 오브젝트(부분). - 출처: https://core.telegram.org/bots/api#user + getMe는 봇 자신을 나타내는 User를 반환한다("Returns basic information about the bot + in form of a User object."). id/is_bot/first_name은 항상 존재, username 등은 선택. + 상류가 추가 필드(can_join_groups 등)를 보내므로 extra는 무시. + 출처: https://core.telegram.org/bots/api#user · https://core.telegram.org/bots/api#getme """ + model_config = {"extra": "ignore"} + id: int is_bot: bool first_name: str + last_name: str | None = None + username: str | None = None class Chat(BaseModel): @@ -105,20 +217,52 @@ class Chat(BaseModel): type: str +class PhotoSize(BaseModel): + """Message.photo 배열의 원소(부분). 사진 1장은 여러 해상도의 PhotoSize 배열로 온다. + + 출처: https://core.telegram.org/bots/api#photosize + """ + + model_config = {"extra": "ignore"} + + file_id: str # 이 파일을 내려받거나 재사용할 식별자 + file_unique_id: str # 시간/봇 불변 고유 식별자(다운로드/재사용 불가) + width: int + height: int + + +class Document(BaseModel): + """Message.document 오브젝트(부분). + + 출처: https://core.telegram.org/bots/api#document + """ + + model_config = {"extra": "ignore"} + + file_id: str + file_unique_id: str + file_name: str | None = None + mime_type: str | None = None + + class Message(BaseModel): - """sendMessage 성공 시 반환되는 Message 오브젝트(MVP 부분 모델). + """send*/editMessageText 성공 시 반환되는 Message 오브젝트(MVP 부분 모델). 공식: "On success, the sent Message is returned." 필수 필드는 message_id, date, chat. + sendPhoto/sendDocument는 각각 photo/document·caption을, editMessageText는 편집된 본문을 채운다. 상류가 추가 필드를 더 보내므로 extra는 무시(검증 실패 방지). 출처: https://core.telegram.org/bots/api#message """ model_config = {"extra": "ignore"} - message_id: int # 채팅 내 고유 메시지 식별자 - date: int # 전송 시각(Unix time) - chat: Chat # 메시지가 속한 채팅 + message_id: int # 채팅 내 고유 메시지 식별자 + date: int # 전송 시각(Unix time) + chat: Chat # 메시지가 속한 채팅 text: str | None = None + caption: str | None = None # 사진/문서 캡션 + photo: list[PhotoSize] | None = None # 사진 메시지의 사이즈 배열 + document: Document | None = None # 문서 메시지 class ApiResponse(BaseModel): @@ -131,6 +275,9 @@ class ApiResponse(BaseModel): """ ok: bool - result: dict | None = None + # result는 메서드에 따라 오브젝트(dict)이거나 Boolean이다. 예: deleteMessage는 True, + # editMessageText는 편집된 Message(dict) 또는 인라인 메시지의 경우 True를 반환한다. + # (출처: #deletemessage "Returns True on success." · #editmessagetext "Message ... or True") + result: dict | bool | None = None error_code: int | None = None description: str | None = None diff --git a/arcsolve/services/telegram/tools.py b/arcsolve/services/telegram/tools.py index b010753..b0782d8 100644 --- a/arcsolve/services/telegram/tools.py +++ b/arcsolve/services/telegram/tools.py @@ -11,7 +11,7 @@ from pydantic import ValidationError from pydantic_settings import BaseSettings, SettingsConfigDict -from arcsolve.http import UpstreamError, post_json +from arcsolve.http import UpstreamError, get_json, post_json from arcsolve.services.telegram import contract as t @@ -93,3 +93,199 @@ async def telegram_send_message( return f"전송 실패: {resp.description or raw}" msg = t.Message.model_validate(resp.result) return f"전송 완료 (message_id={msg.message_id})" + + @mcp.tool + async def telegram_get_me() -> str: + """봇 신원/토큰 유효성을 확인한다(getMe). 헬스체크용. 파라미터 없음. + + 성공 시 봇의 User 정보(id / username / first_name / is_bot)를 돌려준다. + """ + settings = TelegramSettings() + if not settings.bot_token: + return "TELEGRAM_BOT_TOKEN이 설정되지 않았습니다." + + url = t.BASE_URL + t.method_path(settings.bot_token, t.GET_ME) + try: + raw = await get_json(url) + except UpstreamError as e: + return _explain(e) + + resp = t.ApiResponse.model_validate(raw) + if not resp.ok or not isinstance(resp.result, dict): + return f"조회 실패: {resp.description or raw}" + me = t.User.model_validate(resp.result) + handle = f"@{me.username}" if me.username else me.first_name + return f"봇 OK: {handle} (id={me.id}, is_bot={me.is_bot})" + + @mcp.tool + async def telegram_send_photo( + photo: str, + caption: str | None = None, + chat_id: str | None = None, + parse_mode: str | None = None, + ) -> str: + """Telegram 봇으로 사진을 전송한다(sendPhoto). + + Args: + photo: 사진의 **HTTP URL 또는 file_id 문자열**. 로컬 파일 업로드는 미지원 + (코어에 multipart 동사가 없음 — URL/file_id만 지원). + caption: 사진 캡션. 0-1024자. + chat_id: 대상 채팅 ID 또는 "@channelusername". 미지정 시 TELEGRAM_CHAT_ID 사용. + parse_mode: 캡션 서식 모드. "MarkdownV2" 또는 "HTML". 미지정 시 평문. + """ + settings = TelegramSettings() + if not settings.bot_token: + return "TELEGRAM_BOT_TOKEN이 설정되지 않았습니다." + + target = chat_id or settings.chat_id + if not target: + return "chat_id가 없습니다. 인자로 주거나 TELEGRAM_CHAT_ID를 설정하세요." + + try: + req = t.SendPhoto( + chat_id=target, + photo=photo, + caption=caption, + parse_mode=parse_mode, + ) + except ValidationError as e: + return f"입력 오류: {e.errors()[0]['msg']}" + + url = t.BASE_URL + t.method_path(settings.bot_token, t.SEND_PHOTO) + try: + raw = await post_json(url, json=req.model_dump(exclude_none=True)) + except UpstreamError as e: + return _explain(e) + + resp = t.ApiResponse.model_validate(raw) + if not resp.ok or not isinstance(resp.result, dict): + return f"전송 실패: {resp.description or raw}" + msg = t.Message.model_validate(resp.result) + return f"사진 전송 완료 (message_id={msg.message_id})" + + @mcp.tool + async def telegram_send_document( + document: str, + caption: str | None = None, + chat_id: str | None = None, + parse_mode: str | None = None, + ) -> str: + """Telegram 봇으로 문서(파일)를 전송한다(sendDocument). + + Args: + document: 파일의 **HTTP URL 또는 file_id 문자열**. 로컬 파일 업로드는 미지원 + (코어에 multipart 동사가 없음 — URL/file_id만 지원). + caption: 문서 캡션. 0-1024자. + chat_id: 대상 채팅 ID 또는 "@channelusername". 미지정 시 TELEGRAM_CHAT_ID 사용. + parse_mode: 캡션 서식 모드. "MarkdownV2" 또는 "HTML". 미지정 시 평문. + """ + settings = TelegramSettings() + if not settings.bot_token: + return "TELEGRAM_BOT_TOKEN이 설정되지 않았습니다." + + target = chat_id or settings.chat_id + if not target: + return "chat_id가 없습니다. 인자로 주거나 TELEGRAM_CHAT_ID를 설정하세요." + + try: + req = t.SendDocument( + chat_id=target, + document=document, + caption=caption, + parse_mode=parse_mode, + ) + except ValidationError as e: + return f"입력 오류: {e.errors()[0]['msg']}" + + url = t.BASE_URL + t.method_path(settings.bot_token, t.SEND_DOCUMENT) + try: + raw = await post_json(url, json=req.model_dump(exclude_none=True)) + except UpstreamError as e: + return _explain(e) + + resp = t.ApiResponse.model_validate(raw) + if not resp.ok or not isinstance(resp.result, dict): + return f"전송 실패: {resp.description or raw}" + msg = t.Message.model_validate(resp.result) + return f"문서 전송 완료 (message_id={msg.message_id})" + + @mcp.tool + async def telegram_edit_message_text( + text: str, + message_id: int | None = None, + chat_id: str | None = None, + inline_message_id: str | None = None, + parse_mode: str | None = None, + ) -> str: + """메시지의 텍스트를 편집한다(editMessageText). + + 대상 지정은 공식 계약에 따라 둘 중 하나(상호 배타): + - chat_id + message_id : 일반 채팅 메시지 편집(chat_id 미지정 시 TELEGRAM_CHAT_ID). + - inline_message_id : 인라인 모드로 보낸 메시지 편집. + + Args: + text: 새 본문. 1-4096자. + message_id: 편집할 메시지 ID(chat 경로). + chat_id: 대상 채팅 ID 또는 "@channelusername". 미지정 시 TELEGRAM_CHAT_ID. + inline_message_id: 인라인 메시지 ID(지정 시 chat_id/message_id는 생략). + parse_mode: 서식 모드. "MarkdownV2" 또는 "HTML". 미지정 시 평문. + """ + settings = TelegramSettings() + if not settings.bot_token: + return "TELEGRAM_BOT_TOKEN이 설정되지 않았습니다." + + target_chat = None if inline_message_id else (chat_id or settings.chat_id) + try: + req = t.EditMessageText( + chat_id=target_chat, + message_id=message_id, + inline_message_id=inline_message_id, + text=text, + parse_mode=parse_mode, + ) + except ValidationError as e: + return f"입력 오류: {e.errors()[0]['msg']}" + + url = t.BASE_URL + t.method_path(settings.bot_token, t.EDIT_MESSAGE_TEXT) + try: + raw = await post_json(url, json=req.model_dump(exclude_none=True)) + except UpstreamError as e: + return _explain(e) + + resp = t.ApiResponse.model_validate(raw) + if not resp.ok: + return f"편집 실패: {resp.description or raw}" + # editMessageText는 편집된 Message(dict) 또는 인라인의 경우 True를 반환한다. + if isinstance(resp.result, dict): + msg = t.Message.model_validate(resp.result) + return f"편집 완료 (message_id={msg.message_id})" + return "편집 완료" + + @mcp.tool + async def telegram_delete_message(chat_id: str, message_id: int) -> str: + """봇이 접근 가능한 메시지를 삭제한다(deleteMessage). + + Args: + chat_id: 대상 채팅 ID 또는 "@channelusername". + message_id: 삭제할 메시지의 ID. + """ + settings = TelegramSettings() + if not settings.bot_token: + return "TELEGRAM_BOT_TOKEN이 설정되지 않았습니다." + + try: + req = t.DeleteMessage(chat_id=chat_id, message_id=message_id) + except ValidationError as e: + return f"입력 오류: {e.errors()[0]['msg']}" + + url = t.BASE_URL + t.method_path(settings.bot_token, t.DELETE_MESSAGE) + try: + raw = await post_json(url, json=req.model_dump(exclude_none=True)) + except UpstreamError as e: + return _explain(e) + + resp = t.ApiResponse.model_validate(raw) + # deleteMessage는 성공 시 result=True를 반환한다. + if not resp.ok or resp.result is not True: + return f"삭제 실패: {resp.description or raw}" + return f"삭제 완료 (message_id={message_id})" diff --git a/changelog.d/discord.md b/changelog.d/discord.md index 7ef365a..176ff0a 100644 --- a/changelog.d/discord.md +++ b/changelog.d/discord.md @@ -1 +1,2 @@ - **discord**: Webhook 메시지 전송 MCP 추가 — `discord_send_message` +- **discord**: 핵심 도구 확장 — Webhook 임베드/편집/삭제(`discord_send_embed`·`discord_edit_message`·`discord_delete_message`) + Bot 토큰 경로(`discord_create_message`·`discord_list_messages`, `DISCORD_BOT_TOKEN`) diff --git a/changelog.d/line.md b/changelog.d/line.md index 050cd04..4014b70 100644 --- a/changelog.d/line.md +++ b/changelog.d/line.md @@ -1,2 +1,3 @@ - **line**: LINE Messaging API push 텍스트 MCP 추가 — `line_send_text`(전송 메시지 id 반환) - **line**: push 응답 계약을 공식 스펙(`sentMessages[]`)에 맞게 수정, text 길이를 UTF-16 코드 유닛으로 검증 +- **line**: 코어 도구 확장 — `line_reply_text`(reply, sentMessages), `line_multicast_text`(userId 최대 500, 빈 응답), `line_broadcast_text`(빈 응답), `line_get_profile`(Profile 조회) 추가 diff --git a/changelog.d/telegram.md b/changelog.d/telegram.md index 1d1f823..e01ffc7 100644 --- a/changelog.d/telegram.md +++ b/changelog.d/telegram.md @@ -1 +1,2 @@ - **telegram**: sendMessage 기반 telegram_send_message 추가 +- **telegram**: 코어 도구 확장 — getMe(헬스체크)/sendPhoto/sendDocument(URL·file_id만)/editMessageText/deleteMessage 추가 diff --git a/docs/providers.md b/docs/providers.md index 3ce7414..f4563b6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -23,41 +23,51 @@ --- -## telegram — Telegram Bot 메시지 전송 +## telegram — Telegram Bot 메시지 전송/편집/삭제 + 헬스체크 - 상태: `done` - 인증: Bot 토큰 (URL 경로 `/bot/METHOD` — Bearer 아님) - 공식 문서: - Bot API 레퍼런스: https://core.telegram.org/bots/api - - sendMessage: https://core.telegram.org/bots/api#sendmessage + - sendMessage / sendPhoto / sendDocument / editMessageText / deleteMessage / getMe (각 앵커) - 요청/응답 포맷: https://core.telegram.org/bots/api#making-requests - 도구: - `telegram_send_message` — 텍스트(1–4096자) 전송. chat_id 미지정 시 `TELEGRAM_CHAT_ID` -- 스코프(MVP): 포함 = sendMessage(텍스트) / 제외 = 미디어(sendPhoto 등 multipart → 코어 확장 필요), 인라인 키보드 + - `telegram_send_photo` / `telegram_send_document` — 사진·문서 전송(URL·file_id, caption ≤1024) + - `telegram_edit_message_text` / `telegram_delete_message` — 메시지 편집·삭제 + - `telegram_get_me` — 토큰/봇 신원 확인(헬스체크) +- 스코프: 포함 = 텍스트/사진/문서 전송·편집·삭제·getMe / 제외 = **로컬 파일 업로드(multipart)** → 코어 multipart 동사 추가 후, 인라인 키보드·미디어그룹 --- -## discord — Discord Webhook 메시지 전송 +## discord — Discord 메시지 전송/편집/삭제(Webhook) + 채널 전송/조회(Bot) - 상태: `done` -- 인증: Webhook URL (URL 자체가 시크릿, 별도 인증 헤더 없음) +- 인증: Webhook URL(무인증) + (선택) Bot 토큰(`Authorization: Bot` — 채널 직접 전송/조회) - 공식 문서: - - Execute Webhook: https://discord.com/developers/docs/resources/webhook#execute-webhook - - Message Object: https://discord.com/developers/docs/resources/message#message-object + - Execute/Edit/Delete Webhook Message: https://discord.com/developers/docs/resources/webhook + - Create / Get Channel Messages: https://discord.com/developers/docs/resources/message + - Embed 오브젝트: https://discord.com/developers/docs/resources/message#embed-object - 도구: - - `discord_send_message` — content(≤2000자) 전송, username/avatar_url 덮어쓰기 가능 -- 스코프(MVP): 포함 = Execute Webhook(content) / 제외 = Bot 토큰 경로(create-message), embeds·file·components + - `discord_send_message` / `discord_send_embed` — content·리치 임베드 전송(Webhook) + - `discord_edit_message` / `discord_delete_message` — 웹후크 메시지 편집·삭제 + - `discord_create_message` / `discord_list_messages` — Bot 토큰으로 채널 전송·조회 +- 스코프: 포함 = Webhook 전송/임베드/편집/삭제 + Bot 채널 전송/조회 / 제외 = 반응·스레드·첨부파일·components --- -## line — LINE Messaging API push 전송 +## line — LINE Messaging API 메시지 전송(push/reply/multicast/broadcast) + 프로필 - 상태: `done` - 인증: 채널 액세스 토큰 (Bearer) - 공식 문서: - Messaging API 레퍼런스: https://developers.line.biz/en/reference/messaging-api/ - - Send push message: https://developers.line.biz/en/reference/messaging-api/#send-push-message + - push / reply / multicast / broadcast / get-profile (각 앵커) - 채널 액세스 토큰: https://developers.line.biz/en/docs/messaging-api/channel-access-tokens/ - 도구: - - `line_send_text` — 텍스트(≤5000자) push 1건. to 미지정 시 `LINE_TO` -- 스코프(MVP): 포함 = push message(text) / 제외 = reply/multicast/broadcast, sticker·image 등 비텍스트 메시지 + - `line_send_text` — push 1건(≤5000자, UTF-16). to 미지정 시 `LINE_TO` + - `line_reply_text` — replyToken으로 회신 + - `line_multicast_text` — 여러 userId(최대 500)에 동일 텍스트 + - `line_broadcast_text` — 모든 친구에게 전송 + - `line_get_profile` — userId로 프로필 조회 +- 스코프: 포함 = 텍스트 push/reply/multicast/broadcast + 프로필 조회 / 제외 = Flex·template·sticker·image 등 비텍스트, rich menu, webhook 수신 서버 --- diff --git a/docs/services.md b/docs/services.md index 81353f4..82b98e5 100644 --- a/docs/services.md +++ b/docs/services.md @@ -2,13 +2,18 @@ > ⚙️ 자동 생성 — 직접 수정하지 마세요. `arcsolve-mcp catalog`로 재생성됩니다. -현재 **4개 서비스 · 총 5개 도구**. +현재 **4개 서비스 · 총 19개 도구**. ## discord — Discord — Webhook으로 채널에 메시지 전송 공식 문서: https://discord.com/developers/docs/resources/webhook | 도구 | 설명 | |------|------| +| `discord_create_message` | Bot 토큰으로 임의 채널에 메시지를 전송한다. | +| `discord_delete_message` | Webhook이 보낸 기존 메시지를 삭제한다. | +| `discord_edit_message` | Webhook이 보낸 기존 메시지를 편집한다(본문 교체). | +| `discord_list_messages` | Bot 토큰으로 채널의 최근 메시지를 조회한다. | +| `discord_send_embed` | Discord 채널에 Webhook으로 리치 임베드(카드) 1개를 전송한다. | | `discord_send_message` | Discord 채널에 Webhook으로 메시지를 전송한다. | ## kakao — 카카오톡 메시지 — 나에게 보내기 @@ -19,17 +24,26 @@ | `kakao_send_link_to_me` | 카카오톡 '나에게 보내기'로 URL을 스크랩(미리보기 카드) 형태로 전송한다. | | `kakao_send_text_to_me` | 카카오톡 '나에게 보내기'로 텍스트 메시지를 전송한다. | -## line — LINE Messaging API — 텍스트 push 메시지 전송 +## line — LINE Messaging API — 텍스트 메시지 전송(push/reply/multicast/broadcast) + 프로필 조회 공식 문서: https://developers.line.biz/en/reference/messaging-api/ | 도구 | 설명 | |------|------| +| `line_broadcast_text` | LINE Messaging API broadcast로 모든 친구에게 텍스트 1건을 전송한다. | +| `line_get_profile` | LINE Messaging API로 사용자 프로필 정보를 조회한다. | +| `line_multicast_text` | LINE Messaging API multicast로 동일 텍스트를 여러 userId에게 전송한다. | +| `line_reply_text` | LINE Messaging API reply로 텍스트 메시지 1건을 회신한다. | | `line_send_text` | LINE Messaging API push로 텍스트 메시지 1건을 전송한다. | -## telegram — Telegram Bot API — sendMessage로 텍스트 전송 +## telegram — Telegram Bot API — 텍스트/사진/문서 전송, 메시지 편집·삭제, getMe 헬스체크 공식 문서: https://core.telegram.org/bots/api | 도구 | 설명 | |------|------| +| `telegram_delete_message` | 봇이 접근 가능한 메시지를 삭제한다(deleteMessage). | +| `telegram_edit_message_text` | 메시지의 텍스트를 편집한다(editMessageText). | +| `telegram_get_me` | 봇 신원/토큰 유효성을 확인한다(getMe). 헬스체크용. 파라미터 없음. | +| `telegram_send_document` | Telegram 봇으로 문서(파일)를 전송한다(sendDocument). | | `telegram_send_message` | Telegram 봇으로 텍스트 메시지를 전송한다(sendMessage). | +| `telegram_send_photo` | Telegram 봇으로 사진을 전송한다(sendPhoto). | diff --git a/tests/test_discord_contract.py b/tests/test_discord_contract.py index b893109..9e0316c 100644 --- a/tests/test_discord_contract.py +++ b/tests/test_discord_contract.py @@ -6,8 +6,19 @@ from pydantic import ValidationError from arcsolve.services.discord.contract import ( + API_BASE_URL, + CHANNEL_MESSAGES_ROUTE, CONTENT_MAX_LENGTH, + MAX_EMBEDS, + MESSAGES_LIMIT_DEFAULT, + MESSAGES_LIMIT_MAX, + MESSAGES_LIMIT_MIN, WAIT_PARAM, + WEBHOOK_MESSAGE_SUFFIX, + CreateMessageRequest, + EditWebhookMessageRequest, + Embed, + EmbedFooter, ExecuteWebhookRequest, MessageResult, ) @@ -30,12 +41,7 @@ def test_request_omits_optional_when_absent(): assert "username" not in payload assert "avatar_url" not in payload assert "tts" not in payload - - -def test_content_required(): - # content는 본 MVP에서 필수다. - with pytest.raises(ValidationError): - ExecuteWebhookRequest() + assert "embeds" not in payload def test_content_max_length_enforced(): @@ -44,6 +50,14 @@ def test_content_max_length_enforced(): ExecuteWebhookRequest(content="가" * (CONTENT_MAX_LENGTH + 1)) # 2001자는 거부 +def test_execute_webhook_content_optional_for_embed_only(): + # content/embeds 중 하나면 되므로, content 없이 embeds만 있어도 모델은 유효하다. + r = ExecuteWebhookRequest(embeds=[Embed(title="t")]) + payload = json.loads(r.model_dump_json(exclude_none=True)) + assert "content" not in payload + assert payload["embeds"][0]["title"] == "t" + + def test_message_result_partial(): msg = MessageResult.model_validate({"id": "123", "channel_id": "456", "content": "hi"}) assert msg.id == "123" @@ -52,6 +66,84 @@ def test_message_result_partial(): assert MessageResult.model_validate({}).id is None +def test_message_result_parses_author(): + msg = MessageResult.model_validate( + {"id": "1", "content": "hi", "author": {"id": "9", "username": "alice"}} + ) + assert msg.author is not None + assert msg.author.username == "alice" + assert msg.author.id == "9" + + +# ─── Embed 모델 ───────────────────────────────────────────── + + +def test_embed_footer_text_required(): + EmbedFooter(text="ok") # text는 필수 + with pytest.raises(ValidationError): + EmbedFooter() # text 없으면 거부 + + +def test_embed_full_serialization(): + e = Embed( + title="제목", + description="설명", + url="https://x.y", + color=16711680, # color는 RGB 정수 + timestamp="2026-06-01T00:00:00Z", + footer=EmbedFooter(text="footer"), + ) + payload = json.loads(e.model_dump_json(exclude_none=True)) + assert payload["title"] == "제목" + assert payload["color"] == 16711680 + assert isinstance(payload["color"], int) + assert payload["footer"] == {"text": "footer"} # icon_url(None)은 제외 + + +def test_embed_color_must_be_int(): + with pytest.raises(ValidationError): + Embed(color="red") # 문자열은 정수로 강제 불가 + + +def test_execute_webhook_embeds_max_length(): + ten = [Embed(title=str(i)) for i in range(MAX_EMBEDS)] + ExecuteWebhookRequest(embeds=ten) # 10개는 허용 + with pytest.raises(ValidationError): + ExecuteWebhookRequest(embeds=ten + [Embed(title="overflow")]) # 11개는 거부 + + +# ─── Edit / Create 요청 ───────────────────────────────────── + + +def test_edit_request_all_optional(): + # 모든 필드 선택 — 빈 요청도 유효하고, content 길이는 검증된다. + r = EditWebhookMessageRequest(content="새 본문") + assert json.loads(r.model_dump_json(exclude_none=True)) == {"content": "새 본문"} + with pytest.raises(ValidationError): + EditWebhookMessageRequest(content="가" * (CONTENT_MAX_LENGTH + 1)) + + +def test_create_message_request_content_required(): + CreateMessageRequest(content="hi") + with pytest.raises(ValidationError): + CreateMessageRequest() # content 필수 + with pytest.raises(ValidationError): + CreateMessageRequest(content="가" * (CONTENT_MAX_LENGTH + 1)) + + +# ─── 상수 / 라우트 ────────────────────────────────────────── + + def test_contract_constants(): assert CONTENT_MAX_LENGTH == 2000 assert WAIT_PARAM == "wait" + assert MAX_EMBEDS == 10 + assert API_BASE_URL == "https://discord.com/api/v10" + assert MESSAGES_LIMIT_MIN == 1 + assert MESSAGES_LIMIT_MAX == 100 + assert MESSAGES_LIMIT_DEFAULT == 50 + + +def test_route_templates(): + assert CHANNEL_MESSAGES_ROUTE.format(channel_id="42") == "/channels/42/messages" + assert WEBHOOK_MESSAGE_SUFFIX.format(message_id="7") == "/messages/7" diff --git a/tests/test_http.py b/tests/test_http.py index bd967b9..5bcbfbd 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -3,7 +3,7 @@ import httpx import pytest -from arcsolve.http import UpstreamError, get_json, post_form, post_json +from arcsolve.http import UpstreamError, delete_json, get_json, patch_json, post_form, post_json def _t(handler): @@ -42,6 +42,28 @@ async def handler(req): assert await post_json("https://x", json={"k": "v"}, transport=_t(handler)) == {"id": 1} +async def test_patch_json_sends_patch_with_json_body(): + seen = {} + + async def handler(req): + seen["method"] = req.method + seen["body"] = req.content.decode() + return httpx.Response(200, json={"id": "1", "content": "edited"}) + + out = await patch_json("https://x/msg/1", json={"content": "edited"}, transport=_t(handler)) + assert out == {"id": "1", "content": "edited"} + assert seen["method"] == "PATCH" + assert "edited" in seen["body"] + + +async def test_delete_json_returns_empty_dict_on_no_content(): + async def handler(req): + assert req.method == "DELETE" + return httpx.Response(204) + + assert await delete_json("https://x/msg/1", transport=_t(handler)) == {} + + async def test_4xx_raises_upstream_error_with_payload(): async def handler(req): return httpx.Response(401, json={"code": -401, "msg": "bad"}) diff --git a/tests/test_line_contract.py b/tests/test_line_contract.py index 100bfa4..c3db392 100644 --- a/tests/test_line_contract.py +++ b/tests/test_line_contract.py @@ -7,12 +7,22 @@ from arcsolve.services.line.contract import ( BASE_URL, + BROADCAST_MESSAGE, MAX_MESSAGES, + MAX_MULTICAST_RECIPIENTS, MAX_TEXT_LENGTH, + MULTICAST_MESSAGE, + PROFILE, PUSH_MESSAGE, + REPLY_MESSAGE, + BroadcastRequest, + EmptyResult, ErrorResponse, + MulticastRequest, + Profile, PushRequest, PushResult, + ReplyRequest, TextMessage, ) @@ -86,3 +96,135 @@ def test_contract_constants(): assert PUSH_MESSAGE == "/v2/bot/message/push" assert MAX_MESSAGES == 5 assert MAX_TEXT_LENGTH == 5000 + + +# ─── reply ────────────────────────────────────────────────── + + +def test_reply_request_serialization_omits_optional(): + req = ReplyRequest(replyToken="abc", messages=[TextMessage(text="hi")]) + payload = json.loads(req.model_dump_json(exclude_none=True)) + assert payload["replyToken"] == "abc" + assert payload["messages"][0]["text"] == "hi" + assert "notificationDisabled" not in payload # None은 제외 + + +def test_reply_request_requires_token(): + with pytest.raises(ValidationError): + ReplyRequest(replyToken="", messages=[TextMessage(text="hi")]) + + +def test_reply_request_messages_max_five_enforced(): + msgs = [TextMessage(text="m")] + ReplyRequest(replyToken="t", messages=msgs * MAX_MESSAGES) + with pytest.raises(ValidationError): + ReplyRequest(replyToken="t", messages=msgs * (MAX_MESSAGES + 1)) + + +def test_reply_response_uses_sent_messages(): + # 공식: reply도 push와 동일하게 sentMessages[]를 반환. + res = PushResult.model_validate({"sentMessages": [{"id": "461230966842064897"}]}) + assert res.sentMessages[0].id == "461230966842064897" + + +# ─── multicast ────────────────────────────────────────────── + + +def test_multicast_request_serialization(): + req = MulticastRequest(to=["U1", "U2"], messages=[TextMessage(text="hi")]) + payload = json.loads(req.model_dump_json(exclude_none=True)) + assert payload["to"] == ["U1", "U2"] + assert payload["messages"][0]["text"] == "hi" + assert "notificationDisabled" not in payload + + +def test_multicast_to_max_500_enforced(): + # 공식: "Max: 500 user IDs" + assert MAX_MULTICAST_RECIPIENTS == 500 + MulticastRequest(to=["U"] * MAX_MULTICAST_RECIPIENTS, messages=[TextMessage(text="m")]) + with pytest.raises(ValidationError): + MulticastRequest( + to=["U"] * (MAX_MULTICAST_RECIPIENTS + 1), messages=[TextMessage(text="m")] + ) + + +def test_multicast_to_min_one_enforced(): + with pytest.raises(ValidationError): + MulticastRequest(to=[], messages=[TextMessage(text="m")]) + + +def test_multicast_messages_max_five_enforced(): + msgs = [TextMessage(text="m")] + MulticastRequest(to=["U1"], messages=msgs * MAX_MESSAGES) + with pytest.raises(ValidationError): + MulticastRequest(to=["U1"], messages=msgs * (MAX_MESSAGES + 1)) + + +# ─── broadcast ────────────────────────────────────────────── + + +def test_broadcast_request_serialization(): + req = BroadcastRequest(messages=[TextMessage(text="hi")]) + payload = json.loads(req.model_dump_json(exclude_none=True)) + assert payload["messages"][0]["text"] == "hi" + assert "notificationDisabled" not in payload + assert "to" not in payload # broadcast는 to가 없다 + + +def test_broadcast_messages_max_five_enforced(): + msgs = [TextMessage(text="m")] + BroadcastRequest(messages=msgs * MAX_MESSAGES) + with pytest.raises(ValidationError): + BroadcastRequest(messages=msgs * (MAX_MESSAGES + 1)) + + +def test_empty_result_parses_empty_object(): + # 공식: multicast/broadcast 성공 응답은 빈 객체 {}. + EmptyResult.model_validate({}) + EmptyResult.model_validate({"unexpected": 1}) # extra="ignore" + + +# ─── profile ──────────────────────────────────────────────── + + +def test_profile_full_response(): + p = Profile.model_validate( + { + "displayName": "LINE taro", + "userId": "U4af4980629", + "language": "en", + "pictureUrl": "https://profile.line-scdn.net/x", + "statusMessage": "Hello, LINE!", + } + ) + assert p.displayName == "LINE taro" + assert p.userId == "U4af4980629" + assert p.language == "en" + assert p.pictureUrl.startswith("https://") + assert p.statusMessage == "Hello, LINE!" + + +def test_profile_optional_fields_absent(): + # pictureUrl/statusMessage/language는 "Not always included" → 없어도 유효. + p = Profile.model_validate({"displayName": "taro", "userId": "U1"}) + assert p.pictureUrl is None + assert p.statusMessage is None + assert p.language is None + + +def test_profile_requires_display_name_and_user_id(): + with pytest.raises(ValidationError): + Profile.model_validate({"displayName": "taro"}) # userId 누락 + with pytest.raises(ValidationError): + Profile.model_validate({"userId": "U1"}) # displayName 누락 + + +# ─── endpoint constants ───────────────────────────────────── + + +def test_new_endpoint_constants(): + assert REPLY_MESSAGE == "/v2/bot/message/reply" + assert MULTICAST_MESSAGE == "/v2/bot/message/multicast" + assert BROADCAST_MESSAGE == "/v2/bot/message/broadcast" + assert PROFILE == "/v2/bot/profile/{user_id}" + assert PROFILE.format(user_id="U1") == "/v2/bot/profile/U1" diff --git a/tests/test_telegram_contract.py b/tests/test_telegram_contract.py index cfac045..a0402b2 100644 --- a/tests/test_telegram_contract.py +++ b/tests/test_telegram_contract.py @@ -7,12 +7,26 @@ from arcsolve.services.telegram.contract import ( BASE_URL, + CAPTION_MAX_LENGTH, + DELETE_MESSAGE, + EDIT_MESSAGE_TEXT, + GET_ME, PARSE_MODES, + SEND_DOCUMENT, SEND_MESSAGE, + SEND_PHOTO, + TEXT_MAX_LENGTH, ApiResponse, + DeleteMessage, + Document, + EditMessageText, LinkPreviewOptions, Message, + PhotoSize, + SendDocument, SendMessage, + SendPhoto, + User, method_path, ) @@ -89,3 +103,185 @@ def test_contract_constants(): assert BASE_URL == "https://api.telegram.org" assert SEND_MESSAGE == "sendMessage" assert PARSE_MODES == ("MarkdownV2", "HTML") + # 확장 메서드 상수 (공식 메서드 이름과 정확히 일치) + assert GET_ME == "getMe" + assert SEND_PHOTO == "sendPhoto" + assert SEND_DOCUMENT == "sendDocument" + assert EDIT_MESSAGE_TEXT == "editMessageText" + assert DELETE_MESSAGE == "deleteMessage" + # 길이 제약 상수 (공식: caption 0-1024, text 1-4096) + assert CAPTION_MAX_LENGTH == 1024 + assert TEXT_MAX_LENGTH == 4096 + + +# ─── getMe / User ─────────────────────────────────────────── + + +def test_user_parses_getme_result_and_ignores_extra(): + # getMe는 봇의 User를 반환하며 상류 추가 필드(can_join_groups 등)는 무시되어야 한다. + u = User.model_validate( + { + "id": 7, + "is_bot": True, + "first_name": "MyBot", + "username": "my_bot", + "can_join_groups": True, + } + ) + assert u.id == 7 + assert u.is_bot is True + assert u.username == "my_bot" + + +def test_user_optional_fields_default_none(): + u = User.model_validate({"id": 1, "is_bot": True, "first_name": "Bot"}) + assert u.last_name is None + assert u.username is None + + +# ─── sendPhoto / sendDocument ─────────────────────────────── + + +def test_send_photo_serialization_and_target(): + m = SendPhoto(chat_id="@chan", photo="https://example.com/a.jpg", caption="cap") + payload = json.loads(m.model_dump_json(exclude_none=True)) + assert payload == {"chat_id": "@chan", "photo": "https://example.com/a.jpg", "caption": "cap"} + + +def test_send_photo_accepts_file_id_string(): + m = SendPhoto(chat_id=1, photo="AgACAgID-file_id") + assert m.photo == "AgACAgID-file_id" + + +def test_send_photo_empty_source_rejected(): + with pytest.raises(ValidationError): + SendPhoto(chat_id=1, photo="") # URL/file_id 문자열은 비어 있으면 안 됨 + + +def test_caption_max_length_enforced_photo(): + SendPhoto(chat_id=1, photo="u", caption="가" * CAPTION_MAX_LENGTH) # 1024자 허용 + with pytest.raises(ValidationError): + SendPhoto(chat_id=1, photo="u", caption="가" * (CAPTION_MAX_LENGTH + 1)) # 1025자 거부 + + +def test_send_document_serialization_and_caption_limit(): + m = SendDocument(chat_id=1, document="https://example.com/f.pdf", parse_mode="HTML") + payload = json.loads(m.model_dump_json(exclude_none=True)) + assert payload["document"] == "https://example.com/f.pdf" + assert payload["parse_mode"] == "HTML" + SendDocument(chat_id=1, document="d", caption="x" * CAPTION_MAX_LENGTH) + with pytest.raises(ValidationError): + SendDocument(chat_id=1, document="d", caption="x" * (CAPTION_MAX_LENGTH + 1)) + + +def test_send_photo_parse_mode_enum_enforced(): + with pytest.raises(ValidationError): + SendPhoto(chat_id=1, photo="u", parse_mode="bogus") + + +# ─── editMessageText ──────────────────────────────────────── + + +def test_edit_message_text_serialization(): + m = EditMessageText(chat_id="@chan", message_id=42, text="new", parse_mode="MarkdownV2") + payload = json.loads(m.model_dump_json(exclude_none=True)) + assert payload == { + "chat_id": "@chan", + "message_id": 42, + "text": "new", + "parse_mode": "MarkdownV2", + } + + +def test_edit_message_text_length_enforced(): + EditMessageText(chat_id=1, message_id=1, text="가" * TEXT_MAX_LENGTH) # 4096자 허용 + with pytest.raises(ValidationError): + EditMessageText(chat_id=1, message_id=1, text="") # 0자 거부 + with pytest.raises(ValidationError): + EditMessageText(chat_id=1, message_id=1, text="가" * (TEXT_MAX_LENGTH + 1)) # 4097자 거부 + + +def test_edit_message_text_inline_path_ok(): + # inline_message_id 경로(공식 조건부 필수의 다른 한쪽) + m = EditMessageText(inline_message_id="abc123", text="new") + payload = json.loads(m.model_dump_json(exclude_none=True)) + assert payload == {"inline_message_id": "abc123", "text": "new"} + + +def test_edit_message_text_requires_a_target(): + # 두 경로 모두 없으면 거부(공식: 조건부 필수) + with pytest.raises(ValidationError): + EditMessageText(text="new") + # chat_id만 있고 message_id 없으면 chat 경로 미완성 → 거부 + with pytest.raises(ValidationError): + EditMessageText(chat_id=1, text="new") + + +def test_edit_message_text_paths_mutually_exclusive(): + # 두 경로를 함께 주면 거부(공식: 상호 배타) + with pytest.raises(ValidationError): + EditMessageText(chat_id=1, message_id=2, inline_message_id="x", text="new") + + +# ─── deleteMessage ────────────────────────────────────────── + + +def test_delete_message_serialization(): + m = DeleteMessage(chat_id=123, message_id=7) + payload = json.loads(m.model_dump_json(exclude_none=True)) + assert payload == {"chat_id": 123, "message_id": 7} + + +def test_delete_message_requires_message_id(): + with pytest.raises(ValidationError): + DeleteMessage(chat_id=1) # message_id 누락 + + +# ─── 응답 봉투: Boolean result ────────────────────────────── + + +def test_api_response_accepts_boolean_result(): + # deleteMessage는 result=True를, editMessageText는 인라인 시 True를 반환한다. + resp = ApiResponse.model_validate({"ok": True, "result": True}) + assert resp.ok is True + assert resp.result is True + + +def test_message_parses_photo_and_caption(): + # sendPhoto 성공 응답: photo는 PhotoSize 배열, caption 포함. + msg = Message.model_validate( + { + "message_id": 5, + "date": 1, + "chat": {"id": 1, "type": "private"}, + "caption": "hi", + "photo": [ + {"file_id": "fid", "file_unique_id": "u", "width": 90, "height": 90}, + ], + } + ) + assert msg.caption == "hi" + assert msg.photo is not None + assert msg.photo[0].width == 90 + + +def test_message_parses_document(): + msg = Message.model_validate( + { + "message_id": 6, + "date": 1, + "chat": {"id": 1, "type": "private"}, + "document": {"file_id": "fid", "file_unique_id": "u", "file_name": "f.pdf"}, + } + ) + assert msg.document is not None + assert msg.document.file_name == "f.pdf" + + +def test_photosize_and_document_ignore_extra(): + ps = PhotoSize.model_validate( + {"file_id": "a", "file_unique_id": "b", "width": 1, "height": 1, "file_size": 999} + ) + assert ps.file_id == "a" + doc = Document.model_validate({"file_id": "a", "file_unique_id": "b", "mime_type": "x/y"}) + assert doc.mime_type == "x/y"