できました(AI)
# kai_points.py
#
# かい鯖グループポイント Python Binding
#
# Requirements:
# pip install requests
#
# Python 3.9+
from __future__ import annotations
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
import requests
DEFAULT_BASE_URL = "https://points.bac0n.f5.si"
# ============================================================
# Exceptions
# ============================================================
class KaiPointsError(Exception):
"""かい鯖グループポイントAPIの基底例外"""
def __init__(
self,
message: str,
*,
status_code: Optional[int] = None,
response: Optional[requests.Response] = None,
):
super().__init__(message)
self.message = message
self.status_code = status_code
self.response = response
def __str__(self) -> str:
if self.status_code is not None:
return f"[HTTP {self.status_code}] {self.message}"
return self.message
class AuthenticationError(KaiPointsError):
"""401: APIキーが無効または未指定"""
class PaymentRequiredError(KaiPointsError):
"""402: 残高不足"""
class ForbiddenError(KaiPointsError):
"""403: 権限不足 / 取引上限超過など"""
class NotFoundError(KaiPointsError):
"""404: リソースが見つからない"""
class ConflictError(KaiPointsError):
"""409: 現在の状態では操作できない"""
class TooEarlyError(KaiPointsError):
"""425: 課金タイミングがまだ来ていない"""
class RateLimitError(KaiPointsError):
"""429: リクエスト過多"""
class ServerError(KaiPointsError):
"""5xx: サーバー側エラー"""
class InvalidResponseError(KaiPointsError):
"""レスポンスが想定したJSON形式でない"""
# ============================================================
# Data models
# ============================================================
@dataclass(frozen=True)
class Player:
username: str
minecraft_id: str
points: int
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Player":
return cls(
username=str(data["username"]),
minecraft_id=str(data["minecraft_id"]),
points=int(data["points"]),
)
@dataclass(frozen=True)
class Product:
id: int
name: str
price: int
description: Optional[str] = None
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Product":
return cls(
id=int(data["id"]),
name=str(data["name"]),
price=int(data["price"]),
description=data.get("description"),
)
@dataclass(frozen=True)
class TransactionInitiated:
tx_token: str
amount: int
item_name: str
expires_at: Optional[datetime]
web_url: str
message: str
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "TransactionInitiated":
return cls(
tx_token=str(data["tx_token"]),
amount=int(data["amount"]),
item_name=str(data["item_name"]),
expires_at=_parse_datetime(data.get("expires_at")),
web_url=str(data["web_url"]),
message=str(data.get("message", "")),
)
@dataclass(frozen=True)
class Transaction:
status: str
raw: Dict[str, Any]
@property
def pending_buyer(self) -> bool:
return self.status == "pending_buyer"
@property
def pending_seller(self) -> bool:
return self.status == "pending_seller"
@property
def completed(self) -> bool:
return self.status == "completed"
@property
def rejected(self) -> bool:
return self.status == "rejected"
@property
def expired(self) -> bool:
return self.status == "expired"
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Transaction":
return cls(
status=str(data["status"]),
raw=dict(data),
)
@dataclass(frozen=True)
class SubscriptionInitiated:
subscription_id: int
username: str
product_name: str
amount: int
interval_days: int
status: str
web_url: str
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SubscriptionInitiated":
return cls(
subscription_id=int(data["subscription_id"]),
username=str(data["username"]),
product_name=str(data["product_name"]),
amount=int(data["amount"]),
interval_days=int(data["interval_days"]),
status=str(data["status"]),
web_url=str(data["web_url"]),
)
@dataclass(frozen=True)
class Subscription:
id: int
status: str
next_charge_at: Optional[datetime]
raw: Dict[str, Any]
@property
def active(self) -> bool:
return self.status == "active"
@property
def pending(self) -> bool:
return self.status == "pending"
@property
def suspended(self) -> bool:
return self.status == "suspended"
@property
def cancelled(self) -> bool:
return self.status == "cancelled"
@property
def charge_due(self) -> bool:
if self.status != "active":
return False
if self.next_charge_at is None:
return False
return datetime.now(timezone.utc) >= self.next_charge_at
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Subscription":
return cls(
id=int(data["id"]),
status=str(data["status"]),
next_charge_at=_parse_datetime(data.get("next_charge_at")),
raw=dict(data),
)
@dataclass(frozen=True)
class SubscriptionCharge:
status: str
amount: int
next_charge_at: Optional[datetime]
raw: Dict[str, Any]
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SubscriptionCharge":
return cls(
status=str(data["status"]),
amount=int(data["amount"]),
next_charge_at=_parse_datetime(data.get("next_charge_at")),
raw=dict(data),
)
# ============================================================
# Utilities
# ============================================================
def _parse_datetime(value: Any) -> Optional[datetime]:
if value is None:
return None
if isinstance(value, datetime):
dt = value
else:
text = str(value)
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(text)
except ValueError as exc:
raise InvalidResponseError(
f"不正な日時形式です: {value!r}"
) from exc
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
# ============================================================
# Client
# ============================================================
class KaiPointsClient:
def __init__(
self,
api_key: str,
*,
base_url: str = DEFAULT_BASE_URL,
timeout: float = 10.0,
session: Optional[requests.Session] = None,
):
if not api_key:
raise ValueError("api_key は必須です")
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = session or requests.Session()
self.session.headers.update({
"X-API-Key": self.api_key,
"Accept": "application/json",
"User-Agent": "kai-points-python/1.0",
})
# --------------------------------------------------------
# Internal HTTP methods
# --------------------------------------------------------
def _request(
self,
method: str,
path: str,
*,
json: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
authenticated: bool = True,
) -> Any:
url = f"{self.base_url}{path}"
headers: Dict[str, str] = {}
if json is not None:
headers["Content-Type"] = "application/json"
if not authenticated:
headers["X-API-Key"] = ""
try:
response = self.session.request(
method=method,
url=url,
json=json,
params=params,
headers=headers,
timeout=self.timeout,
)
except requests.Timeout as exc:
raise KaiPointsError(
f"APIリクエストがタイムアウトしました: {url}"
) from exc
except requests.RequestException as exc:
raise KaiPointsError(
f"APIへの接続に失敗しました: {exc}"
) from exc
try:
payload = response.json()
except ValueError:
payload = None
if not response.ok:
message = self._extract_error_message(
response=response,
payload=payload,
)
self._raise_http_error(
response=response,
message=message,
)
if not isinstance(payload, dict):
raise InvalidResponseError(
"APIからJSONオブジェクト以外のレスポンスが返されました",
status_code=response.status_code,
response=response,
)
if payload.get("success") is False:
raise KaiPointsError(
str(payload.get("error", "APIエラーが発生しました")),
status_code=response.status_code,
response=response,
)
if "data" not in payload:
raise InvalidResponseError(
"APIレスポンスに data がありません",
status_code=response.status_code,
response=response,
)
return payload["data"]
@staticmethod
def _extract_error_message(
*,
response: requests.Response,
payload: Any,
) -> str:
if isinstance(payload, dict):
error = payload.get("error")
if error:
return str(error)
text = response.text.strip()
if text:
return text
return f"HTTP {response.status_code}"
@staticmethod
def _raise_http_error(
*,
response: requests.Response,
message: str,
) -> None:
status = response.status_code
exception_map = {
401: AuthenticationError,
402: PaymentRequiredError,
403: ForbiddenError,
404: NotFoundError,
409: ConflictError,
425: TooEarlyError,
429: RateLimitError,
}
exc_class = exception_map.get(status)
if exc_class is None:
if status >= 500:
exc_class = ServerError
else:
exc_class = KaiPointsError
raise exc_class(
message,
status_code=status,
response=response,
)
# --------------------------------------------------------
# Account / Player
# --------------------------------------------------------
def get_balance(self) -> Any:
"""
サービスアカウント側の残高情報を取得します。
GET /api/server/balance
詳細なレスポンス形式が固定されていないため、
data をそのまま返します。
"""
return self._request(
"GET",
"/api/server/balance",
)
def get_player(self, minecraft_id: str) -> Player:
"""
Minecraft IDからユーザー情報とポイント残高を取得。
GET /api/server/player/:minecraft_id
"""
if not minecraft_id:
raise ValueError("minecraft_id は必須です")
data = self._request(
"GET",
f"/api/server/player/{requests.utils.quote(minecraft_id, safe='')}",
)
return Player.from_dict(data)
def check_points(self, username: str) -> Any:
"""
フォーラムユーザー名からポイントを確認。
GET /api/points/check?username=...
認証不要。
このエンドポイントのdata形式が仕様上明示されていないため
data をそのまま返します。
"""
if not username:
raise ValueError("username は必須です")
return self._request(
"GET",
"/api/points/check",
params={"username": username},
authenticated=False,
)
# --------------------------------------------------------
# Products
# --------------------------------------------------------
def get_products(self) -> List[Product]:
"""
GET /api/server/products
"""
data = self._request(
"GET",
"/api/server/products",
)
if not isinstance(data, list):
raise InvalidResponseError(
"商品一覧が配列ではありません"
)
return [
Product.from_dict(product)
for product in data
]
# --------------------------------------------------------
# Transactions
# --------------------------------------------------------
def initiate_transaction(
self,
mc_id: str,
product_id: int,
) -> TransactionInitiated:
"""
ポイント取引を開始。
POST /api/server/tx/initiate
"""
if not mc_id:
raise ValueError("mc_id は必須です")
if product_id <= 0:
raise ValueError("product_id は1以上である必要があります")
data = self._request(
"POST",
"/api/server/tx/initiate",
json={
"mc_id": mc_id,
"product_id": product_id,
},
)
return TransactionInitiated.from_dict(data)
def get_transaction(
self,
tx_token: str,
) -> Transaction:
"""
取引ステータスを取得。
GET /api/server/tx/:tx_token
"""
if not tx_token:
raise ValueError("tx_token は必須です")
data = self._request(
"GET",
f"/api/server/tx/{requests.utils.quote(tx_token, safe='')}",
)
return Transaction.from_dict(data)
def approve_transaction(
self,
tx_token: str,
) -> Any:
"""
アイテム付与後、ポイント消費を確定。
POST /api/server/tx/:tx_token/approve
注意:
実際にアイテムや権限を付与した後に呼んでください。
"""
if not tx_token:
raise ValueError("tx_token は必須です")
return self._request(
"POST",
f"/api/server/tx/{requests.utils.quote(tx_token, safe='')}/approve",
)
def wait_for_transaction(
self,
tx_token: str,
*,
interval: float = 5.0,
timeout: Optional[float] = 300.0,
) -> Transaction:
"""
pending_buyer から状態が変わるまでポーリングします。
通常:
pending_buyer
↓ ユーザー承認
pending_seller
Trusted Mode + Auto Approveの場合:
最初から pending_seller の場合があります。
pending_seller / completed / rejected / expired
のいずれかになったら返ります。
"""
if interval <= 0:
raise ValueError("interval は0より大きい必要があります")
started_at = time.monotonic()
while True:
transaction = self.get_transaction(tx_token)
if transaction.status in {
"pending_seller",
"completed",
"rejected",
"expired",
}:
return transaction
if timeout is not None:
elapsed = time.monotonic() - started_at
if elapsed >= timeout:
raise TimeoutError(
f"取引承認待ちがタイムアウトしました: {tx_token}"
)
time.sleep(interval)
# --------------------------------------------------------
# Subscriptions
# --------------------------------------------------------
def initiate_subscription(
self,
username: str,
product_id: int,
interval_days: int,
) -> SubscriptionInitiated:
"""
サブスクリプション登録開始。
POST /api/server/subscription/initiate
"""
if not username:
raise ValueError("username は必須です")
if product_id <= 0:
raise ValueError("product_id は1以上である必要があります")
if interval_days <= 0:
raise ValueError("interval_days は1以上である必要があります")
data = self._request(
"POST",
"/api/server/subscription/initiate",
json={
"username": username,
"product_id": product_id,
"interval_days": interval_days,
},
)
return SubscriptionInitiated.from_dict(data)
def get_subscription(
self,
subscription_id: int,
) -> Subscription:
"""
GET /api/server/subscription/:id
"""
if subscription_id <= 0:
raise ValueError("subscription_id は1以上である必要があります")
data = self._request(
"GET",
f"/api/server/subscription/{subscription_id}",
)
return Subscription.from_dict(data)
def get_subscriptions(self) -> List[Subscription]:
"""
GET /api/server/subscription
"""
data = self._request(
"GET",
"/api/server/subscription",
)
if not isinstance(data, list):
raise InvalidResponseError(
"サブスクリプション一覧が配列ではありません"
)
return [
Subscription.from_dict(subscription)
for subscription in data
]
def charge_subscription(
self,
subscription_id: int,
) -> SubscriptionCharge:
"""
定期課金を実行。
POST /api/server/subscription/:id/charge
Raises:
TooEarlyError:
next_charge_atがまだ未来
PaymentRequiredError:
残高不足。サブスクはsuspendedへ移行済み
ConflictError:
active以外の状態
"""
if subscription_id <= 0:
raise ValueError("subscription_id は1以上である必要があります")
data = self._request(
"POST",
f"/api/server/subscription/{subscription_id}/charge",
)
return SubscriptionCharge.from_dict(data)
def charge_subscription_if_due(
self,
subscription_id: int,
) -> Optional[SubscriptionCharge]:
"""
next_charge_atを確認し、
課金期限が到来している場合のみ /charge を呼びます。
課金不要の場合は None。
"""
subscription = self.get_subscription(subscription_id)
if not subscription.active:
return None
if not subscription.charge_due:
return None
return self.charge_subscription(subscription_id)
def charge_due_subscriptions(
self,
) -> Dict[int, Any]:
"""
サービスの全サブスクを確認し、
課金期限が到来しているactiveサブスクを課金します。
サービス起動時や定期ジョブから呼ぶ用途を想定。
戻り値:
{
subscription_id: SubscriptionCharge | KaiPointsError | None
}
None:
課金対象外
KaiPointsError:
課金中にエラー
"""
results: Dict[int, Any] = {}
subscriptions = self.get_subscriptions()
for subscription in subscriptions:
if not subscription.active:
results[subscription.id] = None
continue
if not subscription.charge_due:
results[subscription.id] = None
continue
try:
results[subscription.id] = self.charge_subscription(
subscription.id
)
except KaiPointsError as exc:
results[subscription.id] = exc
return results
def cancel_subscription(
self,
subscription_id: int,
) -> Any:
"""
サービス側からサブスクリプションをキャンセル。
DELETE /api/server/subscription/:id
"""
if subscription_id <= 0:
raise ValueError("subscription_id は1以上である必要があります")
return self._request(
"DELETE",
f"/api/server/subscription/{subscription_id}",
)
def wait_for_subscription(
self,
subscription_id: int,
*,
interval: float = 5.0,
timeout: Optional[float] = 300.0,
) -> Subscription:
"""
サブスクリプションの初回承認を待ちます。
active / suspended / cancelled
になった時点で返ります。
"""
if interval <= 0:
raise ValueError("interval は0より大きい必要があります")
started_at = time.monotonic()
while True:
subscription = self.get_subscription(subscription_id)
if subscription.status in {
"active",
"suspended",
"cancelled",
}:
return subscription
if timeout is not None:
elapsed = time.monotonic() - started_at
if elapsed >= timeout:
raise TimeoutError(
"サブスクリプション承認待ちが"
f"タイムアウトしました: {subscription_id}"
)
time.sleep(interval)
# --------------------------------------------------------
# Context Manager
# --------------------------------------------------------
def close(self) -> None:
self.session.close()
def __enter__(self) -> "KaiPointsClient":
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.close()
# ============================================================
# Basic usage
# ============================================================
#
# from kai_points import (
# KaiPointsClient,
# PaymentRequiredError,
# TooEarlyError,
# )
#
# client = KaiPointsClient("skp_...")
#
#
# # プレイヤー残高
# player = client.get_player("Steve")
# print(player.points)
#
#
# # 商品一覧
# for product in client.get_products():
# print(product.id, product.name, product.price)
#
#
# # 通常購入
# tx = client.initiate_transaction(
# mc_id="Steve",
# product_id=1,
# )
#
# print(tx.web_url)
#
# status = client.wait_for_transaction(tx.tx_token)
#
# if status.pending_seller:
# # ここでMinecraftサーバーなどにアイテムを付与する
# #
# # give_item(...)
# #
# # 付与が成功した場合のみapprove
# client.approve_transaction(tx.tx_token)
#
#
# # サブスク登録
# sub = client.initiate_subscription(
# username="User123",
# product_id=1,
# interval_days=30,
# )
#
# print(sub.web_url)
#
# active_sub = client.wait_for_subscription(
# sub.subscription_id
# )
#
# if active_sub.active:
# print("サブスク有効")
# print(active_sub.next_charge_at)
#
#
# # 定期実行
# try:
# charge = client.charge_subscription_if_due(
# sub.subscription_id
# )
#
# if charge is not None:
# print("課金成功")
# print(charge.amount)
# print(charge.next_charge_at)
#
# except PaymentRequiredError:
# print("ポイント残高不足")
#
# except TooEarlyError:
# # 複数プロセス等が同時に判定した場合など
# # GET時点では期限到来していても、
# # 別プロセスが先に課金した可能性がある
# print("まだ課金時期ではありません")
#
#
# # サービス起動時などに全サブスクをチェック
# results = client.charge_due_subscriptions()
#
# for subscription_id, result in results.items():
# if isinstance(result, SubscriptionCharge):
# print(
# subscription_id,
# "課金成功",
# result.next_charge_at,
# )
# elif isinstance(result, KaiPointsError):
# print(
# subscription_id,
# "課金失敗",
# result,
# )
というかサービスアカウントの仮想作成作るか
サービスアカウントを論理的に分離する
ポイントとられたのに商品もらえなかったり商品もらえたのにポイントとられなかったりしないようにテストはしておいた方がいいよ
まあねー
というか公式がPythonバインディング作りゃいい話でしょ
kai_points_mock.py.txt (30.2 KB)
というわけでモックサーバーを用意しました!