File size: 13,671 Bytes
1ce31ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
"""
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()