any-env-code / channel_auth.py
izuemon's picture
Create channel_auth.py
1ce31ee verified
Raw History Blame Contribute Delete
13.7 kB
"""
ChannelWorks クライアントライブラリ
使い方:
from channel_works import ChannelWorksClient
client = ChannelWorksClient(
email="you@example.com",
password="your-password",
log_level=ChannelWorksClient.LOG_DEBUG,
)
client.login() # sign_in + boot
client.start_auto_touch() # 10分ごとに自動で touch を叩いてトークン更新
tokens = client.get_tokens() # 最新トークンを取得
...
client.stop_auto_touch()
log_level:
0 = 何も出力しない
1 = エラー・警告のみ (デフォルト)
2 = デバッグログも出力
"""
import json
import threading
from collections import OrderedDict
from urllib.parse import urlencode
import requests
SIGN_IN_URI = "https://api.channel.works/desk/account/sign-in"
BOOT_URI = "https://api.channel.io/front/v8/elastic/plugins/b823a684-f97e-4e4e-bf12-48b70c1939ae/boot"
TOUCH_URI = "https://api.channel.works/desk/account/touch"
BOOT_URL_TEMPLATE = "https://channel.works/{room_id}/user-chats"
BOOT_AVATAR_URL = "https://cf.channel.io/avatar/emoji/bee.f7dc8e.png"
DEFAULT_TOUCH_INTERVAL = 600 # 10 分
# Set-Cookie から抜き出す対象トークン
_TOKEN_NAMES = ("x-account", "x-account-refresh", "ch-session-1", "ch-veil-id")
class ChannelWorksClient:
# ログレベル
LOG_NONE = 0
LOG_ERROR = 1
LOG_DEBUG = 2
def __init__(
self,
room_id,
email=None,
password=None,
log_level=LOG_ERROR,
touch_interval=DEFAULT_TOUCH_INTERVAL,
timeout=30,
):
if not room_id:
raise ValueError("room_id は必須です。")
self.room_id = room_id
self.email = email
self.password = password
self.log_level = log_level
self.touch_interval = touch_interval
self.timeout = timeout
self._lock = threading.RLock()
# 最新トークン(この値は常に上書きされる。固定値にはならない)
self._x_account = None
self._x_account_refresh = None
self._ch_session_1 = None
self._ch_veil_id = None
# boot に必要なアカウント情報
self._account_name = None
self._account_email = None
self._account_id = None
self._member_hash = None
# 自動 touch 用スレッド
self._touch_thread = None
self._touch_stop = threading.Event()
# ------------------------------------------------------------------
# ログ
# ------------------------------------------------------------------
def _log_debug(self, msg):
if self.log_level >= self.LOG_DEBUG:
print(f"[DEBUG] {msg}")
def _log_error(self, msg):
if self.log_level >= self.LOG_ERROR:
print(f"[ERROR] {msg}")
def set_log_level(self, level):
self.log_level = level
# ------------------------------------------------------------------
# 内部ユーティリティ
# ------------------------------------------------------------------
@staticmethod
def _common_headers():
return {
"accept": "application/json",
"accept-language": "ja",
"content-type": "application/json",
"priority": "u=1, i",
"sec-ch-ua": "\"Chromium\";v=\"152\", \"Not?A_Brand\";v=\"24\", \"Google Chrome\";v=\"152\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"Referer": "https://channel.works/",
}
@staticmethod
def _get_set_cookie_headers(resp):
try:
headers = resp.raw.headers.getlist("Set-Cookie")
if headers:
return headers
except Exception:
pass
raw = resp.headers.get("Set-Cookie")
return [raw] if raw else []
@staticmethod
def _extract_new_tokens(set_cookie_headers):
new_tokens = {}
for set_cookie in set_cookie_headers:
first_part = set_cookie.split(";", 1)[0].strip()
if "=" not in first_part:
continue
name, value = first_part.split("=", 1)
name = name.strip()
if name in _TOKEN_NAMES and value:
new_tokens[name] = value
return new_tokens
def _build_cookie_header(self):
cookies = OrderedDict()
if self._ch_session_1:
cookies["ch-session-1"] = self._ch_session_1
if self._ch_veil_id:
cookies["ch-veil-id"] = self._ch_veil_id
if self._x_account:
cookies["x-account"] = self._x_account
if self._x_account_refresh:
cookies["x-account-refresh"] = self._x_account_refresh
cookies["x-account-refresh-bound"] = "false"
return "; ".join(f"{k}={v}" for k, v in cookies.items())
def _apply_new_tokens(self, new_tokens):
"""Set-Cookie から取得した新トークンを保持値に反映する。"""
with self._lock:
if "x-account" in new_tokens:
self._x_account = new_tokens["x-account"]
if "x-account-refresh" in new_tokens:
self._x_account_refresh = new_tokens["x-account-refresh"]
if "ch-session-1" in new_tokens:
self._ch_session_1 = new_tokens["ch-session-1"]
if "ch-veil-id" in new_tokens:
self._ch_veil_id = new_tokens["ch-veil-id"]
# ------------------------------------------------------------------
# 認証
# ------------------------------------------------------------------
def sign_in(self):
if not self.email or not self.password:
raise ValueError("email と password を指定してください。")
headers = self._common_headers()
headers["x-supports-refresh"] = "true"
body = json.dumps({"email": self.email, "password": self.password}).encode("utf-8")
self._log_debug(f"sign_in POST {SIGN_IN_URI}")
resp = requests.post(SIGN_IN_URI, headers=headers, data=body, timeout=self.timeout)
if not (200 <= resp.status_code < 300):
self._log_error(f"sign_in 失敗 status={resp.status_code} body={resp.text}")
return resp
set_cookies = self._get_set_cookie_headers(resp)
new_tokens = self._extract_new_tokens(set_cookies)
self._apply_new_tokens(new_tokens)
self._log_debug(f"sign_in 取得トークン: {sorted(new_tokens.keys())}")
if not self._x_account or not self._x_account_refresh:
self._log_error("sign_in 応答から x-account / x-account-refresh を取得できませんでした。")
try:
data = resp.json()
except ValueError:
self._log_error("sign_in 応答の JSON パースに失敗しました。")
return resp
account_info = data["account"]
with self._lock:
self._account_name = account_info["name"]
self._account_email = account_info["email"]
self._account_id = account_info["id"]
self._member_hash = data["memberHash"]
self._log_debug("sign_in 成功")
return resp
def boot(self):
if not self._account_id or not self._member_hash:
self._log_error("boot 前に sign_in を実行してください。")
return None
profile = json.dumps(
{
"name": self._account_name,
"email": self._account_email,
"avatarUrl": BOOT_AVATAR_URL,
},
ensure_ascii=False,
separators=(",", ":"),
)
boot_url_value = BOOT_URL_TEMPLATE.format(room_id=self.room_id)
body = urlencode({
"url": boot_url_value, # ← 変更
"profile": profile,
"memberId": self._account_id,
"memberHash": self._member_hash,
}).encode("utf-8")
headers = {
"accept": "application/json",
"accept-language": "ja",
"content-type": "application/x-www-form-urlencoded",
"priority": "u=1, i",
"sec-ch-ua": "\"Chromium\";v=\"152\", \"Not?A_Brand\";v=\"24\", \"Google Chrome\";v=\"152\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "cross-site",
"sec-fetch-storage-access": "none",
"Referer": "https://channel.works/",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " # User-Agentは必須
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/152.0.0.0 Safari/537.36"
),
}
self._log_debug(f"boot POST {BOOT_URI} memberId={self._account_id}")
resp = requests.post(BOOT_URI, headers=headers, data=body, timeout=self.timeout)
if not (200 <= resp.status_code < 300):
self._log_error(f"boot 失敗 status={resp.status_code} body={resp.text}")
return resp
# boot 応答で ch-session-1 / ch-veil-id が降ってくる場合に備えて反映
self._apply_new_tokens(self._extract_new_tokens(self._get_set_cookie_headers(resp)))
try:
data = resp.json()
except ValueError:
self._log_error("boot 応答の JSON パースに失敗しました。")
return resp
with self._lock:
self._ch_session_1 = data.get("sessionJWT", self._ch_session_1)
self._ch_veil_id = data.get("veilId", self._ch_veil_id)
self._log_debug("boot 成功")
return resp
def login(self):
"""sign_in → boot をまとめて実行する。"""
self.sign_in()
self.boot()
# ------------------------------------------------------------------
# touch
# ------------------------------------------------------------------
def touch(self):
"""最新トークンを使って touch を 1 回叩き、返ってきた新トークンで保持値を更新する。"""
with self._lock:
if not self._x_account:
self._log_error("touch 実行不可: x-account 未取得")
return None
account = self._x_account
cookie_header = self._build_cookie_header()
headers = self._common_headers()
headers["x-account"] = account
headers["Cookie"] = cookie_header
body = b'{"refreshOnly":true,"skipSessionExtension":false}'
self._log_debug(f"touch POST {TOUCH_URI}")
try:
resp = requests.post(TOUCH_URI, headers=headers, data=body, timeout=self.timeout)
except Exception as e:
self._log_error(f"touch 通信例外: {e!r}")
return None
if not (200 <= resp.status_code < 300):
self._log_error(f"touch 失敗 status={resp.status_code} body={resp.text}")
return resp
new_tokens = self._extract_new_tokens(self._get_set_cookie_headers(resp))
self._apply_new_tokens(new_tokens)
self._log_debug(f"touch 成功 更新トークン: {sorted(new_tokens.keys()) or '(更新なし)'}")
return resp
# ------------------------------------------------------------------
# トークン取得
# ------------------------------------------------------------------
def get_tokens(self):
"""常に最新のトークンを辞書で返す。"""
with self._lock:
return {
"x-account": self._x_account,
"x-account-refresh": self._x_account_refresh,
"ch-session-1": self._ch_session_1,
"ch-veil-id": self._ch_veil_id,
}
# ------------------------------------------------------------------
# 自動 touch
# ------------------------------------------------------------------
def start_auto_touch(self):
"""バックグラウンドで touch_interval 秒ごとに touch を実行する。"""
with self._lock:
if self._touch_thread and self._touch_thread.is_alive():
self._log_debug("auto-touch は既に起動中")
return
self._touch_stop.clear()
self._touch_thread = threading.Thread(
target=self._touch_loop, name="ChannelWorksAutoTouch", daemon=True
)
self._touch_thread.start()
self._log_debug(f"auto-touch 開始 (interval={self.touch_interval}s)")
def stop_auto_touch(self):
"""自動 touch を停止する。"""
self._touch_stop.set()
thread = self._touch_thread
if thread and thread.is_alive():
thread.join(timeout=5)
self._touch_thread = None
self._log_debug("auto-touch 停止")
def _touch_loop(self):
while not self._touch_stop.wait(self.touch_interval):
try:
self.touch()
except Exception as e:
self._log_error(f"auto-touch 例外: {e!r}")
# ------------------------------------------------------------------
# コンテキストマネージャ
# ------------------------------------------------------------------
def close(self):
self.stop_auto_touch()
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
self.close()