lineoa 4.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lineoa-4.4.3/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 もやし
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,203 @@
1
+ from typing import Optional, List, Dict, Any, Callable
2
+ import urllib.parse
3
+ import requests
4
+ import os
5
+ import json
6
+ import time
7
+ from .exceptions import LINEOAError
8
+ from selenium import webdriver
9
+ from selenium.webdriver.chrome.options import Options
10
+
11
+ class AuthService:
12
+ def get_uid_map_from_at_ids(self, at_id_list: List[str], chat_service: Any) -> Dict[str, str]:
13
+ """
14
+ Get a map from @ID list to U-ID (internal ID)
15
+ :param at_id_list: ['@xxxx', ...]
16
+ :param chat_service: ChatService instance
17
+ :return: dict {@id: u_id}
18
+ """
19
+ uid_map = {}
20
+ try:
21
+ bot_accounts = chat_service.get_bot_accounts()
22
+ for bot in bot_accounts.get('list', []):
23
+ at_id = bot.get('basicSearchId')
24
+ u_id = bot.get('botId')
25
+ if at_id and u_id and at_id in at_id_list:
26
+ uid_map[at_id] = u_id
27
+ except Exception as e:
28
+ LINEOAError(f"Failed to get UID map from @IDs: {e}")
29
+ return uid_map
30
+ def __init__(self, channel_id: Optional[str] = None, channel_secret: Optional[str] = None, access_token: Optional[str] = None, cookie_store_path: Optional[str] = None):
31
+ self.channel_id = channel_id
32
+ self.channel_secret = channel_secret
33
+ self.access_token = access_token
34
+ self.cookie_store_path = cookie_store_path
35
+
36
+ def login_with_email_and_2fa(self, email: Optional[str], password: Optional[str], get_2fa_code_callback: Optional[Callable], recaptcha_response: str = "", stay_logged_in: bool = True, xsrf_token: Optional[str] = None, cookies: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
37
+ # Cookie storage
38
+ if self.cookie_store_path and os.path.exists(self.cookie_store_path):
39
+ if os.path.getsize(self.cookie_store_path) == 0:
40
+ raise LINEOAError("Cookie storage load error: cookie file is empty. Please save logged-in cookies.")
41
+ try:
42
+ with open(self.cookie_store_path, "r", encoding="utf-8") as f:
43
+ data = json.load(f)
44
+ if data.get("email") == email and "cookies" in data:
45
+ session = requests.Session()
46
+ for c in data["cookies"]:
47
+ session.cookies.set(c["name"], c["value"], domain=c.get("domain"))
48
+ chat_cookies = {c["name"] for c in data["cookies"] if c.get("domain") == "chat.line.biz"}
49
+ for c in data["cookies"]:
50
+ if c["name"] not in chat_cookies and c.get("domain") in [".line.biz", ".manager.line.biz", "manager.line.biz", "account.line.biz"]:
51
+ session.cookies.set(c["name"], c["value"], domain="chat.line.biz")
52
+ user_info = {"user_name": data.get("user_name")}
53
+ return {"session": session, "user_info": user_info}
54
+ except Exception as e:
55
+ raise LINEOAError(f"Cookie storage load error: {e}")
56
+ if email is None and password is None:
57
+ session = requests.Session()
58
+ user_info = {"user_name": None}
59
+ login_url = "https://account.line.biz/login?redirectUri=https%3A%2F%2Faccount.line.biz%2Foauth2%2Fcallback%3Fclient_id%3D10%26code_challenge%3D4x53SnbmZOYxDeiDFpINCIeh9t1HYiSmIY2E7CblxVY%26code_challenge_method%3DS256%26redirect_uri%3Dhttps%253A%252F%252Fmanager.line.biz%252Fapi%252Foauth2%252FbizId%252Fcallback%26response_type%3Dcode%26state%3DUxTSXVJiwgOWD4cnrk68RCXBwhPLPkBI"
60
+ chrome_options = Options()
61
+ chrome_options.add_experimental_option("detach", True)
62
+ driver = webdriver.Chrome(options=chrome_options)
63
+ driver.get(login_url)
64
+ while True:
65
+ time.sleep(2)
66
+ try:
67
+ current_url = driver.current_url
68
+ if current_url.startswith("https://manager.line.biz/"):
69
+ break
70
+ except Exception as e:
71
+ raise LINEOAError(f"Error while checking current URL: {e}")
72
+ driver.get("https://chat.line.biz/")
73
+ time.sleep(2)
74
+ driver.get("https://chat.line.biz/api/v1/bots?limit=1000&noFilter=true")
75
+ time.sleep(2)
76
+ bots_json = None
77
+ try:
78
+ pre = driver.find_element("tag name", "pre")
79
+ bots_json = json.loads(pre.text)
80
+ except Exception:
81
+ try:
82
+ bots_json = json.loads(driver.find_element("tag name", "body").text)
83
+ except Exception as e:
84
+ raise LINEOAError(f"Failed to parse bots JSON: {e}")
85
+ bot_ids = [b["botId"] for b in bots_json.get("list", []) if b.get("botId", "").startswith("U")]
86
+ all_cookies = driver.get_cookies()[:]
87
+ for bot_id in bot_ids:
88
+ url = f"https://chat.line.biz/{bot_id}"
89
+ driver.get(url)
90
+ time.sleep(2)
91
+ all_cookies.extend(driver.get_cookies())
92
+ driver.quit()
93
+ seen = set()
94
+ combined_cookies_to_save = []
95
+ for cookie in all_cookies:
96
+ try:
97
+ key = (cookie['name'], cookie.get('domain'))
98
+ if key not in seen:
99
+ combined_cookies_to_save.append({
100
+ "name": cookie['name'],
101
+ "value": cookie['value'],
102
+ "domain": cookie.get('domain')
103
+ })
104
+ seen.add(key)
105
+ except Exception as e:
106
+ raise LINEOAError(f"Error while processing cookies: {e}")
107
+ for c in combined_cookies_to_save:
108
+ try:
109
+ session.cookies.set(c["name"], c["value"], domain=c.get("domain"))
110
+ except Exception as e:
111
+ raise LINEOAError(f"Error while setting session cookies: {e}")
112
+ if self.cookie_store_path:
113
+ try:
114
+ with open(self.cookie_store_path, "w", encoding="utf-8") as f:
115
+ json.dump({
116
+ "user_name": user_info.get("user_name"),
117
+ "cookies": combined_cookies_to_save
118
+ }, f, ensure_ascii=False, indent=2)
119
+ except Exception as e:
120
+ raise LINEOAError(f"Cookie storage save error: {e}")
121
+ return {"session": session, "user_info": user_info, "bot_ids": bot_ids}
122
+ raise LINEOAError("login_with_email_and_2fa failed: no valid login path")
123
+
124
+ def login_and_get_token(self, email: str, password: str, client_id: str, code_challenge: str, redirect_uri: str, state: str, session: Optional[requests.Session] = None) -> Optional[str]:
125
+ """
126
+ Automate OAuth2 authentication flow with email and password only to obtain authorization code (code) template
127
+ :param email: Email address
128
+ :param password: Password
129
+ :param client_id: OAuth2 client ID
130
+ :param code_challenge: PKCE challenge
131
+ :param redirect_uri: Redirect URI
132
+ :param state: state parameter
133
+ :param session: requests.Session (newly created if omitted)
134
+ :return: code (authorization code) or None
135
+ """
136
+ session = session or requests.Session()
137
+ xsrf_resp = session.get("https://chat.line.biz/api/v1/csrfToken")
138
+ xsrf_token = xsrf_resp.json().get("token")
139
+ login_resp = self.login_with_email(
140
+ email, password, recaptcha_response="", stay_logged_in=True, xsrf_token=xsrf_token, cookies=session.cookies.get_dict()
141
+ )
142
+ if login_resp.get("status") == "needReCaptchaVerification":
143
+ raise LINEOAError("reCAPTCHA verification is required. Manual intervention or external service integration is needed.")
144
+ params = {
145
+ "client_id": client_id,
146
+ "code_challenge": code_challenge,
147
+ "code_challenge_method": "S256",
148
+ "redirect_uri": redirect_uri,
149
+ "response_type": "code",
150
+ "state": state,
151
+ "status": "success"
152
+ }
153
+ auth_url = "https://account.line.biz/oauth2/callback?" + urllib.parse.urlencode(params)
154
+ resp = session.get(auth_url, allow_redirects=False)
155
+ if resp.status_code == 302 and "location" in resp.headers:
156
+ loc = resp.headers["location"]
157
+ parsed = urllib.parse.urlparse(loc)
158
+ query = urllib.parse.parse_qs(parsed.query)
159
+ code = query.get("code", [None])[0]
160
+ return code
161
+ raise LINEOAError("Failed to obtain authorization code")
162
+
163
+ def get_access_token(self) -> str:
164
+ """
165
+ Extend to implement access token acquisition API, etc.
166
+ :return: Access token string
167
+ :raises LINEOAError: When not set
168
+ """
169
+ if self.access_token:
170
+ return self.access_token
171
+ raise LINEOAError("Access Token is not set")
172
+
173
+ def login_with_email(self, email: str, password: str, recaptcha_response: str = "", stay_logged_in: bool = True, xsrf_token: Optional[str] = None, cookies: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
174
+ """
175
+ Log in to LINE Business Account with email and password
176
+ POST https://account.line.biz/api/login/email
177
+ :param email: Email address
178
+ :param password: Password
179
+ :param recaptcha_response: reCAPTCHA response (if needed)
180
+ :param stay_logged_in: Stay logged in
181
+ :param xsrf_token: XSRF token (if needed)
182
+ :param cookies: Session cookies (if needed)
183
+ :return: dict (API response)
184
+ """
185
+ url = "https://account.line.biz/api/login/email"
186
+ headers = {
187
+ "Content-Type": "application/json",
188
+ "Accept": "application/json, text/plain, */*"
189
+ }
190
+ if xsrf_token:
191
+ headers["x-xsrf-token"] = xsrf_token
192
+ payload = {
193
+ "email": email,
194
+ "password": password,
195
+ "gRecaptchaResponse": recaptcha_response,
196
+ "stayLoggedIn": stay_logged_in
197
+ }
198
+ try:
199
+ response = requests.post(url, headers=headers, json=payload, cookies=cookies)
200
+ response.raise_for_status()
201
+ return response.json()
202
+ except Exception as e:
203
+ raise LINEOAError(f"login_with_email failed: {e}")
@@ -0,0 +1,253 @@
1
+ from typing import Optional, List, Dict, Any, Callable
2
+ from .AuthService import AuthService
3
+ from .chatService import ChatService
4
+ from .exceptions import LINEOAError
5
+ import os
6
+ import requests
7
+ import json
8
+ import time
9
+ import random
10
+
11
+ class LINELib:
12
+ def get_streaming_api_token_and_listen_stream_events(self, bot_id: str, device_type: str = "", client_type: str = "PC", ping_secs: int = 60, last_event_id: Optional[str] = None, on_event: Optional[Callable[[Dict[str, Any]], None]] = None) -> None:
13
+ """
14
+ streamingApiToken取得→SSE接続を一連で行う
15
+ :param bot_id: BotのID
16
+ :param device_type: デバイスタイプ(省略可)
17
+ :param client_type: クライアントタイプ(デフォルト: PC)
18
+ :param ping_secs: ping間隔(デフォルト: 60秒)
19
+ :param last_event_id: 前回受信したイベントID(省略可)
20
+ :param on_event: イベント受信時のコールバック (dict)
21
+ """
22
+ try:
23
+ token_info = self._chat_service.get_streaming_api_token(bot_id, session=self._session, xsrf_token=self._xsrf_token)
24
+ streaming_api_token = token_info.get("streamingApiToken")
25
+ if not isinstance(streaming_api_token, str) or not streaming_api_token:
26
+ raise LINEOAError("streamingApiToken is missing or invalid")
27
+ last_event_id = last_event_id or token_info.get("lastEventId")
28
+ for event in self._chat_service.stream_events(
29
+ streaming_api_token,
30
+ device_type=device_type,
31
+ client_type=client_type,
32
+ ping_secs=ping_secs,
33
+ last_event_id=last_event_id,
34
+ session=self._session,
35
+ xsrf_token=self._xsrf_token
36
+ ):
37
+ if on_event:
38
+ on_event(event)
39
+ except Exception as e:
40
+ print("[EXCEPTION in get_streaming_api_token_and_listen_stream_events]", e)
41
+
42
+ def get_chat_members(self, bot_id=None, chat_id=None, limit: int = 100) -> Dict[str, Any]:
43
+ """チャットメンバー一覧取得"""
44
+ return self._chat_service.get_chat_members(
45
+ bot_id=str(bot_id), chat_id=str(chat_id), limit=limit, session=self._session, xsrf_token=self._xsrf_token
46
+ )
47
+
48
+ def send_file(self, chat_id: str, file_path: str, bot_id: Optional[str] = None) -> Dict[str, Any]:
49
+ """
50
+ 指定チャットにファイルを送信
51
+ :param chat_id: チャットID
52
+ :param file_path: ファイルパス
53
+ :param bot_id: 利用するbotId(省略時は先頭bot)
54
+ """
55
+ if bot_id is None:
56
+ bot_id = next(iter(self.bots.ids.values()), None)
57
+ if not bot_id:
58
+ raise LINEOAError("No bot found")
59
+ return self._chat_service.send_file(
60
+ bot_id, chat_id, file_path, session=self._session, xsrf_token=self._xsrf_token
61
+ )
62
+
63
+ def listen_stream_events(self, streaming_api_token: str, device_type: str = "", client_type: str = "PC", ping_secs: int = 60, last_event_id: Optional[str] = None, on_event: Optional[Callable[[Dict[str, Any]], None]] = None) -> None:
64
+ """
65
+ chat-streaming-api.line.biz SSEイベント受信
66
+ :param streaming_api_token: SSE用トークン
67
+ :param device_type: デバイスタイプ(省略可)
68
+ :param client_type: クライアントタイプ(デフォルト: PC)
69
+ :param ping_secs: ping間隔(デフォルト: 60秒)
70
+ :param last_event_id: 前回受信したイベントID(省略可)
71
+ :param on_event: イベント受信時のコールバック (dict)
72
+ """
73
+ for event in self._chat_service.stream_events(
74
+ streaming_api_token,
75
+ device_type=device_type,
76
+ client_type=client_type,
77
+ ping_secs=ping_secs,
78
+ last_event_id=last_event_id,
79
+ session=self._session,
80
+ xsrf_token=self._xsrf_token
81
+ ):
82
+ if on_event:
83
+ on_event(event)
84
+
85
+ def get_chat_messages(self, bot_id: str, chat_id: str, limit: int = 50, before: Optional[str] = None, after: Optional[str] = None) -> Dict[str, Any]:
86
+ """
87
+ 指定チャットのメッセージ一覧を取得 (公式Webクライアント完全再現)
88
+ :param bot_id: BotのID
89
+ :param chat_id: チャットID
90
+ :param limit: 取得件数
91
+ :param before: これより前のメッセージID(任意)
92
+ :param after: これより後のメッセージID(任意)
93
+ :return: dict (list: メッセージ情報配列)
94
+ """
95
+ return self._chat_service.get_chat_messages(
96
+ bot_id, chat_id,
97
+ session=self._session,
98
+ xsrf_token=self._xsrf_token,
99
+ limit=limit,
100
+ before=before,
101
+ after=after
102
+ )
103
+
104
+ def __init__(self, storage: Optional[str] = None):
105
+ self.storage = storage or "lineoa-storage.json"
106
+ self._auth = AuthService(cookie_store_path=self.storage)
107
+ self._session = None
108
+ self._user_info = None
109
+ self._xsrf_token = None
110
+ try:
111
+ self._restore_session_from_cookie()
112
+ except LINEOAError as e:
113
+ login_result = self._auth.login_with_email_and_2fa(None, None, get_2fa_code_callback=None)
114
+ self._session = login_result.get("session")
115
+ self._user_info = login_result.get("user_info")
116
+ for c in self._session.cookies:
117
+ if c.name == "XSRF-TOKEN" and "chat.line.biz" in c.domain:
118
+ self._xsrf_token = c.value
119
+ break
120
+ if self._session is None:
121
+ self._session = requests.Session()
122
+ self._chat_service = ChatService("")
123
+ self._bots = None
124
+ self._chats = None
125
+ self._provider = None
126
+
127
+ def listen_messages(self, bot_id: str, chat_id: str, on_message: Optional[Callable[[Dict[str, Any]], None]] = None) -> None:
128
+ """
129
+ 指定チャットのメッセージをリアルタイムで監視 (SSE)
130
+ :param bot_id: BotのID
131
+ :param chat_id: チャットID
132
+ :param on_message: 新着メッセージ受信時のコールバック (dict)
133
+ """
134
+ return self._chat_service.listen_messages(bot_id, chat_id, on_message)
135
+
136
+ def send_message(self, user_id: str, context: str, bot_id: Optional[str] = None, quoteToken: Optional[str] = None) -> Dict[str, Any]:
137
+ """
138
+ 指定ユーザーにテキストメッセージを送信
139
+ :param user_id: チャットID(ユーザーID)
140
+ :param context: 送信するテキスト
141
+ :param bot_id: 利用するbotId(省略時は先頭bot)
142
+ :param quoteToken: リプライ用(省略時は普通のメッセージ)
143
+ """
144
+ if bot_id is None:
145
+ bot_id = next(iter(self.bots.ids.values()), None)
146
+ if not bot_id:
147
+ raise LINEOAError("No bot found")
148
+ now = int(time.time() * 1000)
149
+ send_id = f"{user_id}_{now}_{random.randint(1000000,9999999)}"
150
+ payload = {
151
+ "id": "",
152
+ "type": "textV2",
153
+ "text": context,
154
+ "sendId": send_id
155
+ }
156
+ if quoteToken:
157
+ payload["quoteToken"] = quoteToken
158
+ return self._chat_service.send_message(
159
+ bot_id, user_id, payload, session=self._session, xsrf_token=self._xsrf_token
160
+ )
161
+
162
+ def _restore_session_from_cookie(self) -> None:
163
+ if not os.path.exists(self.storage):
164
+ raise LINEOAError("cookie storage does not exist. Please save logged-in cookies.")
165
+ if os.path.getsize(self.storage) == 0:
166
+ raise LINEOAError("cookie storage is empty. Please save logged-in cookies.")
167
+ with open(self.storage, "r", encoding="utf-8") as f:
168
+ data = json.load(f)
169
+ if "cookies" not in data:
170
+ raise LINEOAError("cookie storage is invalid")
171
+ session = requests.Session()
172
+ for c in data["cookies"]:
173
+ session.cookies.set(c["name"], c["value"], domain=c.get("domain"))
174
+ self._session = session
175
+ self._user_info = {"email": data.get("email"), "user_name": data.get("user_name")}
176
+ for c in session.cookies:
177
+ if c.name == "XSRF-TOKEN" and "chat.line.biz" in c.domain:
178
+ self._xsrf_token = c.value
179
+ break
180
+
181
+ @property
182
+ def bots(self):
183
+ if self._bots is None:
184
+ bots = self._chat_service.get_bot_accounts(session=self._session, xsrf_token=self._xsrf_token)
185
+ # bots: list of dicts with 'botId' and 'name'
186
+ self._bots = BotsInfo(bots.get("list", []))
187
+ return self._bots
188
+
189
+ @property
190
+ def chats(self):
191
+ if self._chats is None:
192
+ bot_id = next(iter(self.bots.ids.values()), None)
193
+ if not bot_id:
194
+ raise LINEOAError("No bot found")
195
+ try:
196
+ chats = self._chat_service.get_chats(bot_id, session=self._session, xsrf_token=self._xsrf_token)
197
+ self._chats = ChatsInfo(chats.get("list", []))
198
+ except Exception as e:
199
+ raise LINEOAError(f"チャット一覧取得失敗: {e}")
200
+ return self._chats
201
+
202
+ @property
203
+ def provider(self):
204
+ if self._provider is None:
205
+ try:
206
+ url = "https://chat.line.biz/api/v1/providers"
207
+ headers = {
208
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
209
+ "Accept": "application/json, text/plain, */*",
210
+ }
211
+ if self._session is None:
212
+ self._session = requests.Session()
213
+ resp = self._session.get(url, headers=headers)
214
+ if resp.ok:
215
+ self._provider = resp.json()
216
+ else:
217
+ self._provider = []
218
+ raise LINEOAError(f"get Provider Not found: {resp.status_code} {resp.text}")
219
+ except Exception as e:
220
+ self._provider = []
221
+ raise LINEOAError(f"get Provider Error: {e}")
222
+ return self._provider
223
+
224
+ class BotsInfo:
225
+ def __init__(self, bots_list: List[Dict[str, Any]]):
226
+ self._bots = bots_list
227
+ @property
228
+ def ids(self) -> Dict[str, str]:
229
+ result = {}
230
+ for b in self._bots:
231
+ u_id = b.get("botId")
232
+ at_id = b.get("basicSearchId")
233
+ name = b.get("name", "")
234
+ if u_id:
235
+ key = at_id if at_id else name
236
+ result[key] = u_id
237
+ return result
238
+
239
+ class ChatsInfo:
240
+ def __init__(self, chats_list: List[Dict[str, Any]]):
241
+ self._chats = chats_list
242
+ self.group = ChatTypeIds(self._chats, "GROUP")
243
+ self.user = ChatTypeIds(self._chats, "USER")
244
+
245
+ class ChatTypeIds:
246
+ def __init__(self, chats: List[Dict[str, Any]], chat_type: str):
247
+ self._ids = [c["chatId"] for c in chats if c.get("chatType") == chat_type]
248
+ @property
249
+ def ids(self) -> List[str]:
250
+ return self._ids
251
+
252
+
253
+
@@ -0,0 +1,19 @@
1
+ from .linebot import LineBot
2
+
3
+ __all__ = ["LineBot"]
4
+ from .chatService import ChatService
5
+ from .AuthService import AuthService
6
+ from .exceptions import LINEOAError
7
+ from .util import merge_dicts
8
+ from .LINELib import LINELib
9
+ from typing import Any
10
+
11
+ __all__ = ["ChatService", "AuthService", "LINEOAError", "merge_dicts"]
12
+ __author__ = "madoa5561"
13
+ __version__ = "4.4.3"
14
+ __license__ = "MIT"
15
+
16
+
17
+
18
+
19
+