python-mytnb 0.1.0__py3-none-any.whl
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.
- mytnb/__init__.py +26 -0
- mytnb/__main__.py +5 -0
- mytnb/auth.py +67 -0
- mytnb/cli.py +379 -0
- mytnb/client/__init__.py +3 -0
- mytnb/client/auth.py +159 -0
- mytnb/client/client.py +239 -0
- mytnb/client/config.py +35 -0
- mytnb/client/legacy.py +120 -0
- mytnb/client/rest.py +117 -0
- mytnb/crypto.py +142 -0
- mytnb/exceptions.py +47 -0
- mytnb/models.py +236 -0
- python_mytnb-0.1.0.dist-info/METADATA +165 -0
- python_mytnb-0.1.0.dist-info/RECORD +18 -0
- python_mytnb-0.1.0.dist-info/WHEEL +4 -0
- python_mytnb-0.1.0.dist-info/entry_points.txt +2 -0
- python_mytnb-0.1.0.dist-info/licenses/LICENSE +21 -0
mytnb/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Python library for the myTNB API."""
|
|
2
|
+
|
|
3
|
+
from mytnb.client import MyTNBClient
|
|
4
|
+
from mytnb.crypto import EncryptedPayload, encrypt_request
|
|
5
|
+
from mytnb.models import (
|
|
6
|
+
AccountUsage,
|
|
7
|
+
BillingMonth,
|
|
8
|
+
CostMetric,
|
|
9
|
+
DailyUsage,
|
|
10
|
+
Metric,
|
|
11
|
+
TariffBlock,
|
|
12
|
+
UsageMetric,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MyTNBClient",
|
|
17
|
+
"EncryptedPayload",
|
|
18
|
+
"encrypt_request",
|
|
19
|
+
"AccountUsage",
|
|
20
|
+
"BillingMonth",
|
|
21
|
+
"CostMetric",
|
|
22
|
+
"DailyUsage",
|
|
23
|
+
"Metric",
|
|
24
|
+
"TariffBlock",
|
|
25
|
+
"UsageMetric",
|
|
26
|
+
]
|
mytnb/__main__.py
ADDED
mytnb/auth.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Authentication handling for myTNB API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class DeviceInfo:
|
|
11
|
+
"""Device information sent with API requests."""
|
|
12
|
+
|
|
13
|
+
device_id: str
|
|
14
|
+
app_version: str = "4.0.2"
|
|
15
|
+
os_type: str = "2" # 1=Android, 2=iOS
|
|
16
|
+
os_version: str = "18.0"
|
|
17
|
+
device_desc: str = "EN"
|
|
18
|
+
version_code: str = "1425"
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"deviceId": self.device_id,
|
|
23
|
+
"appVersion": self.app_version,
|
|
24
|
+
"osType": self.os_type,
|
|
25
|
+
"osVersion": self.os_version,
|
|
26
|
+
"deviceDesc": self.device_desc,
|
|
27
|
+
"versionCode": self.version_code,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class UserInfo:
|
|
33
|
+
"""User information for authenticated requests."""
|
|
34
|
+
|
|
35
|
+
user_name: str
|
|
36
|
+
user_id: str
|
|
37
|
+
display_name: str = ""
|
|
38
|
+
role_id: str = "16"
|
|
39
|
+
language: str = "EN"
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict:
|
|
42
|
+
return {
|
|
43
|
+
"RoleId": self.role_id,
|
|
44
|
+
"UserId": self.user_id,
|
|
45
|
+
"UserName": self.user_name,
|
|
46
|
+
"Lang": self.language,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Credentials:
|
|
52
|
+
"""API credentials for myTNB."""
|
|
53
|
+
|
|
54
|
+
# REST API (api.mytnb.com.my)
|
|
55
|
+
api_key: str
|
|
56
|
+
authorization_token: str
|
|
57
|
+
# Optional bearer token for some endpoints
|
|
58
|
+
bearer_token: Optional[str] = None
|
|
59
|
+
# Channel API key (static JWT)
|
|
60
|
+
channel_api_key: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
# Legacy API (mytnbapp.tnb.com.my)
|
|
63
|
+
secure_key: Optional[str] = None
|
|
64
|
+
|
|
65
|
+
# User context
|
|
66
|
+
user_info: Optional[UserInfo] = None
|
|
67
|
+
device_info: Optional[DeviceInfo] = None
|
mytnb/cli.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Command-line interface for myTNB API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.json import JSON
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from mytnb.auth import Credentials, DeviceInfo, UserInfo
|
|
18
|
+
from mytnb.client import MyTNBClient
|
|
19
|
+
from mytnb.exceptions import MyTNBError
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
err_console = Console(stderr=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ─── Exception handling ──────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _handle_exception(debug: bool, exc: BaseException) -> None:
|
|
29
|
+
"""Handle exceptions with nice output."""
|
|
30
|
+
if isinstance(exc, click.ClickException):
|
|
31
|
+
raise exc
|
|
32
|
+
if isinstance(exc, click.exceptions.Exit):
|
|
33
|
+
sys.exit(exc.code)
|
|
34
|
+
if isinstance(exc, click.exceptions.Abort):
|
|
35
|
+
sys.exit(0)
|
|
36
|
+
|
|
37
|
+
if isinstance(exc, MyTNBError):
|
|
38
|
+
code = f" [{exc.error_code}]" if exc.error_code else ""
|
|
39
|
+
err_console.print(f"[bold red]Error[/] ({type(exc).__name__}{code}): {exc}")
|
|
40
|
+
else:
|
|
41
|
+
err_console.print(f"[bold red]Error[/]: {exc}")
|
|
42
|
+
|
|
43
|
+
if debug:
|
|
44
|
+
err_console.print_exception()
|
|
45
|
+
else:
|
|
46
|
+
err_console.print("[dim]Run with --debug to see the full traceback[/]")
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CatchAllGroup(click.Group):
|
|
51
|
+
"""Click Group that catches all exceptions and prints them nicely."""
|
|
52
|
+
|
|
53
|
+
def invoke(self, ctx: click.Context):
|
|
54
|
+
try:
|
|
55
|
+
return super().invoke(ctx)
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
_handle_exception(ctx.params.get("debug", False), exc)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def main(self, *args, **kwargs):
|
|
61
|
+
try:
|
|
62
|
+
return super().main(*args, **kwargs)
|
|
63
|
+
except KeyboardInterrupt:
|
|
64
|
+
err_console.print("\n[yellow]Aborted![/]")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_config(path: str | None = None) -> dict:
|
|
72
|
+
"""Load credentials from a JSON config file."""
|
|
73
|
+
candidates = []
|
|
74
|
+
if path:
|
|
75
|
+
candidates.append(Path(path))
|
|
76
|
+
if env := os.environ.get("MYTNB_CONFIG"):
|
|
77
|
+
candidates.append(Path(env))
|
|
78
|
+
candidates.append(Path("mytnb.json"))
|
|
79
|
+
candidates.append(Path.home() / ".config" / "mytnb" / "config.json")
|
|
80
|
+
|
|
81
|
+
for p in candidates:
|
|
82
|
+
if p.is_file():
|
|
83
|
+
with open(p, encoding="utf-8") as f:
|
|
84
|
+
return json.load(f)
|
|
85
|
+
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _build_credentials(cfg: dict) -> Credentials:
|
|
90
|
+
"""Build Credentials from a config dict."""
|
|
91
|
+
user_info = None
|
|
92
|
+
if "user" in cfg:
|
|
93
|
+
u = cfg["user"]
|
|
94
|
+
user_info = UserInfo(
|
|
95
|
+
user_name=u.get("user_name", ""),
|
|
96
|
+
user_id=u.get("user_id", ""),
|
|
97
|
+
language=u.get("language", "EN"),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
device_info = None
|
|
101
|
+
if "device" in cfg:
|
|
102
|
+
d = cfg["device"]
|
|
103
|
+
device_info = DeviceInfo(
|
|
104
|
+
device_id=d.get("device_id", ""),
|
|
105
|
+
app_version=d.get("app_version", "4.0.2"),
|
|
106
|
+
os_type=d.get("os_type", "2"),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return Credentials(
|
|
110
|
+
api_key=cfg.get("api_key", ""),
|
|
111
|
+
authorization_token=cfg.get("authorization_token", ""),
|
|
112
|
+
bearer_token=cfg.get("bearer_token"),
|
|
113
|
+
channel_api_key=cfg.get("channel_api_key"),
|
|
114
|
+
secure_key=cfg.get("secure_key"),
|
|
115
|
+
user_info=user_info,
|
|
116
|
+
device_info=device_info,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _to_json(data: object) -> str:
|
|
121
|
+
"""Serialize any object to JSON string."""
|
|
122
|
+
if hasattr(data, "model_dump"):
|
|
123
|
+
data = data.model_dump()
|
|
124
|
+
elif hasattr(data, "__dict__") and not isinstance(data, dict):
|
|
125
|
+
data = data.__dict__
|
|
126
|
+
return json.dumps(data, indent=2, default=str)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _print_json(data: object) -> None:
|
|
130
|
+
"""Pretty-print any object as highlighted JSON."""
|
|
131
|
+
console.print(JSON(_to_json(data)))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_async(coro):
|
|
135
|
+
"""Run an async coroutine."""
|
|
136
|
+
return asyncio.run(coro)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _get_client(ctx: click.Context) -> MyTNBClient:
|
|
140
|
+
"""Build and return a MyTNBClient from CLI context."""
|
|
141
|
+
email = ctx.obj.get("email")
|
|
142
|
+
password = ctx.obj.get("password")
|
|
143
|
+
config_path = ctx.obj.get("config")
|
|
144
|
+
|
|
145
|
+
if email and password:
|
|
146
|
+
with console.status("[bold green]Logging in..."):
|
|
147
|
+
return await MyTNBClient.login(email, password)
|
|
148
|
+
|
|
149
|
+
cfg = _load_config(config_path)
|
|
150
|
+
if not cfg:
|
|
151
|
+
raise click.UsageError(
|
|
152
|
+
"No credentials. Use --email/--password, set MYTNB_EMAIL/MYTNB_PASSWORD "
|
|
153
|
+
"env vars, or create a config file (run: mytnb init-config)"
|
|
154
|
+
)
|
|
155
|
+
creds = _build_credentials(cfg)
|
|
156
|
+
staging = cfg.get("staging", False)
|
|
157
|
+
return MyTNBClient(creds, use_staging_key=staging)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ─── CLI definition ─────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@click.group(cls=CatchAllGroup)
|
|
164
|
+
@click.option("-c", "--config", help="Path to config JSON file.")
|
|
165
|
+
@click.option("-e", "--email", envvar="MYTNB_EMAIL", help="myTNB account email.")
|
|
166
|
+
@click.option("-p", "--password", envvar="MYTNB_PASSWORD", help="myTNB account password.")
|
|
167
|
+
@click.option("--debug", is_flag=True, help="Show full traceback on errors.")
|
|
168
|
+
@click.version_option(package_name="python-mytnb")
|
|
169
|
+
@click.pass_context
|
|
170
|
+
def cli(ctx, config, email, password, debug):
|
|
171
|
+
"""CLI for the myTNB API (Tenaga Nasional Berhad)."""
|
|
172
|
+
ctx.ensure_object(dict)
|
|
173
|
+
ctx.obj["config"] = config
|
|
174
|
+
ctx.obj["email"] = email
|
|
175
|
+
ctx.obj["password"] = password
|
|
176
|
+
ctx.obj["debug"] = debug
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@cli.command()
|
|
180
|
+
@click.pass_context
|
|
181
|
+
def login(ctx):
|
|
182
|
+
"""Test login and show user info."""
|
|
183
|
+
|
|
184
|
+
async def _login():
|
|
185
|
+
client = await _get_client(ctx)
|
|
186
|
+
async with client:
|
|
187
|
+
ui = client.credentials.user_info
|
|
188
|
+
table = Table(title="Login Successful", show_header=False)
|
|
189
|
+
table.add_column("Field", style="bold cyan")
|
|
190
|
+
table.add_column("Value")
|
|
191
|
+
table.add_row("User ID", ui.user_id)
|
|
192
|
+
table.add_row("Email", ui.user_name)
|
|
193
|
+
table.add_row("Display Name", getattr(ui, "display_name", "") or "")
|
|
194
|
+
console.print(table)
|
|
195
|
+
|
|
196
|
+
_run_async(_login())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@cli.command()
|
|
200
|
+
@click.argument("account")
|
|
201
|
+
@click.option("--json", "as_json", is_flag=True, help="Output full JSON instead of table.")
|
|
202
|
+
@click.option("--daily", is_flag=True, help="Show daily usage breakdown instead of monthly.")
|
|
203
|
+
@click.pass_context
|
|
204
|
+
def usage(ctx, account, as_json, daily):
|
|
205
|
+
"""Get smart meter usage & billing data."""
|
|
206
|
+
|
|
207
|
+
async def _usage():
|
|
208
|
+
client = await _get_client(ctx)
|
|
209
|
+
async with client:
|
|
210
|
+
with console.status("[bold green]Fetching usage data..."):
|
|
211
|
+
result = await client.get_account_usage_smart(account)
|
|
212
|
+
|
|
213
|
+
if as_json:
|
|
214
|
+
_print_json(result)
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
if daily:
|
|
218
|
+
# Show daily usage table
|
|
219
|
+
for period in result.by_day:
|
|
220
|
+
table = Table(title=f"Daily Usage — {period.range}")
|
|
221
|
+
table.add_column("Date", style="cyan")
|
|
222
|
+
table.add_column("Usage (kWh)", justify="right")
|
|
223
|
+
table.add_column("Cost (RM)", justify="right", style="green")
|
|
224
|
+
for day in period.days:
|
|
225
|
+
table.add_row(
|
|
226
|
+
day.date,
|
|
227
|
+
day.consumption or "0",
|
|
228
|
+
day.amount or "0.00",
|
|
229
|
+
)
|
|
230
|
+
console.print(table)
|
|
231
|
+
console.print()
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
# Show monthly summary table
|
|
235
|
+
table = Table(title=f"Usage — {account}")
|
|
236
|
+
table.add_column("Month", style="cyan")
|
|
237
|
+
table.add_column("Usage (kWh)", justify="right")
|
|
238
|
+
table.add_column("Amount (RM)", justify="right", style="green")
|
|
239
|
+
|
|
240
|
+
if result.by_month:
|
|
241
|
+
for m in result.by_month.months:
|
|
242
|
+
table.add_row(
|
|
243
|
+
f"{m.month} {m.year}",
|
|
244
|
+
m.usage_total or "--",
|
|
245
|
+
m.amount_total or "--",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
console.print(table)
|
|
249
|
+
|
|
250
|
+
# Current usage metrics
|
|
251
|
+
if result.usage_metrics or result.cost_metrics:
|
|
252
|
+
console.print()
|
|
253
|
+
for metric in result.usage_metrics:
|
|
254
|
+
if metric.value and metric.value != "--":
|
|
255
|
+
console.print(f" [cyan]{metric.title}:[/] {metric.value} {metric.value_unit}")
|
|
256
|
+
for metric in result.cost_metrics:
|
|
257
|
+
if metric.value and metric.value != "--":
|
|
258
|
+
console.print(f" [green]{metric.title}:[/] {metric.value_unit} {metric.value}")
|
|
259
|
+
|
|
260
|
+
_run_async(_usage())
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@cli.command("current-usage")
|
|
264
|
+
@click.argument("account")
|
|
265
|
+
@click.pass_context
|
|
266
|
+
def current_usage(ctx, account):
|
|
267
|
+
"""Get simplified current usage summary."""
|
|
268
|
+
|
|
269
|
+
async def _current():
|
|
270
|
+
client = await _get_client(ctx)
|
|
271
|
+
async with client:
|
|
272
|
+
with console.status("[bold green]Fetching..."):
|
|
273
|
+
result = await client.get_current_usage(account)
|
|
274
|
+
_print_json(result)
|
|
275
|
+
|
|
276
|
+
_run_async(_current())
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@cli.command("due-amount")
|
|
280
|
+
@click.argument("account")
|
|
281
|
+
@click.option("--json", "as_json", is_flag=True, help="Output full JSON.")
|
|
282
|
+
@click.pass_context
|
|
283
|
+
def due_amount(ctx, account, as_json):
|
|
284
|
+
"""Get account outstanding balance."""
|
|
285
|
+
|
|
286
|
+
async def _due():
|
|
287
|
+
client = await _get_client(ctx)
|
|
288
|
+
async with client:
|
|
289
|
+
with console.status("[bold green]Fetching..."):
|
|
290
|
+
result = await client.get_account_due_amount(account)
|
|
291
|
+
|
|
292
|
+
if as_json:
|
|
293
|
+
_print_json(result)
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
data = result.get("AccountAmountDue", result) if isinstance(result, dict) else result
|
|
297
|
+
if isinstance(data, dict):
|
|
298
|
+
amount = data.get("amountDue", "--")
|
|
299
|
+
due_date = data.get("billDueDate", "--")
|
|
300
|
+
console.print(Panel(
|
|
301
|
+
f"[bold green]RM {amount}[/]\nDue by [cyan]{due_date}[/]",
|
|
302
|
+
title=f"Due Amount — {account}",
|
|
303
|
+
))
|
|
304
|
+
else:
|
|
305
|
+
_print_json(result)
|
|
306
|
+
|
|
307
|
+
_run_async(_due())
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@cli.command("bill-history")
|
|
311
|
+
@click.argument("account")
|
|
312
|
+
@click.option("--json", "as_json", is_flag=True, help="Output full JSON.")
|
|
313
|
+
@click.pass_context
|
|
314
|
+
def bill_history(ctx, account, as_json):
|
|
315
|
+
"""Get bill payment history."""
|
|
316
|
+
|
|
317
|
+
async def _history():
|
|
318
|
+
client = await _get_client(ctx)
|
|
319
|
+
async with client:
|
|
320
|
+
with console.status("[bold green]Fetching..."):
|
|
321
|
+
result = await client.get_bill_history(account)
|
|
322
|
+
|
|
323
|
+
if as_json:
|
|
324
|
+
_print_json(result)
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
if isinstance(result, list):
|
|
328
|
+
table = Table(title=f"Bill History — {account}")
|
|
329
|
+
table.add_column("Date", style="cyan")
|
|
330
|
+
table.add_column("Bill No")
|
|
331
|
+
table.add_column("Amount (RM)", justify="right", style="green")
|
|
332
|
+
for bill in result:
|
|
333
|
+
table.add_row(
|
|
334
|
+
bill.get("DtBill", "--"),
|
|
335
|
+
bill.get("BillingNo", "--"),
|
|
336
|
+
bill.get("AmPayable", "--"),
|
|
337
|
+
)
|
|
338
|
+
console.print(table)
|
|
339
|
+
else:
|
|
340
|
+
_print_json(result)
|
|
341
|
+
|
|
342
|
+
_run_async(_history())
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@cli.command("init-config")
|
|
346
|
+
@click.option("-o", "--output", default="mytnb.json", help="Output path.")
|
|
347
|
+
def init_config(output):
|
|
348
|
+
"""Generate a starter config file."""
|
|
349
|
+
target = Path(output)
|
|
350
|
+
if target.exists():
|
|
351
|
+
raise click.ClickException(f"{target} already exists")
|
|
352
|
+
|
|
353
|
+
template = {
|
|
354
|
+
"api_key": "",
|
|
355
|
+
"authorization_token": "",
|
|
356
|
+
"secure_key": "",
|
|
357
|
+
"user": {
|
|
358
|
+
"user_name": "",
|
|
359
|
+
"user_id": "",
|
|
360
|
+
"language": "EN",
|
|
361
|
+
},
|
|
362
|
+
"device": {
|
|
363
|
+
"device_id": "",
|
|
364
|
+
"app_version": "4.0.2",
|
|
365
|
+
"os_type": "2",
|
|
366
|
+
},
|
|
367
|
+
}
|
|
368
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
370
|
+
json.dump(template, f, indent=2)
|
|
371
|
+
console.print(f"[green]Config written to[/] {target}")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def main() -> None:
|
|
375
|
+
cli() # pylint: disable=no-value-for-parameter
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
if __name__ == "__main__":
|
|
379
|
+
main()
|
mytnb/client/__init__.py
ADDED
mytnb/client/auth.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Authentication login flow for myTNB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from mytnb.auth import Credentials, DeviceInfo, UserInfo
|
|
15
|
+
from mytnb.client.config import (
|
|
16
|
+
DEFAULT_API_KEY,
|
|
17
|
+
REST_BASE_URL,
|
|
18
|
+
SITECORE_LOGIN_URL,
|
|
19
|
+
SSO_HANDLER_URL,
|
|
20
|
+
USER_AGENT,
|
|
21
|
+
)
|
|
22
|
+
from mytnb.exceptions import AuthenticationError, GeoBlockedError
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from mytnb.client.client import MyTNBClient
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def login(
|
|
31
|
+
cls: type[MyTNBClient],
|
|
32
|
+
email: str,
|
|
33
|
+
password: str,
|
|
34
|
+
*,
|
|
35
|
+
device_id: str | None = None,
|
|
36
|
+
timeout: float = 30.0,
|
|
37
|
+
use_staging_key: bool = False,
|
|
38
|
+
) -> MyTNBClient:
|
|
39
|
+
"""Authenticate with email and password, returning a ready-to-use client.
|
|
40
|
+
|
|
41
|
+
Performs the full login flow:
|
|
42
|
+
1. Authenticate via Sitecore web login (plaintext credentials)
|
|
43
|
+
2. Submit SSO form to get user identity (userId)
|
|
44
|
+
3. Generate an access token via the REST Identity API
|
|
45
|
+
"""
|
|
46
|
+
if not device_id:
|
|
47
|
+
device_id = str(uuid.uuid4()).upper()
|
|
48
|
+
|
|
49
|
+
device_info = DeviceInfo(device_id=device_id)
|
|
50
|
+
|
|
51
|
+
async with httpx.AsyncClient(
|
|
52
|
+
follow_redirects=False,
|
|
53
|
+
timeout=timeout,
|
|
54
|
+
headers={"User-Agent": USER_AGENT},
|
|
55
|
+
) as http:
|
|
56
|
+
await http.get("https://www.mytnb.com.my/")
|
|
57
|
+
|
|
58
|
+
login_resp = await http.post(
|
|
59
|
+
SITECORE_LOGIN_URL,
|
|
60
|
+
data={"Email": email, "Password": password},
|
|
61
|
+
headers={
|
|
62
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
63
|
+
"Origin": "https://www.mytnb.com.my",
|
|
64
|
+
"Referer": "https://www.mytnb.com.my/",
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
logger.info("Sitecore login response: %s", login_resp.status_code)
|
|
68
|
+
|
|
69
|
+
if login_resp.status_code == 403:
|
|
70
|
+
raise GeoBlockedError()
|
|
71
|
+
if login_resp.status_code != 200:
|
|
72
|
+
raise AuthenticationError(
|
|
73
|
+
"Login failed",
|
|
74
|
+
error_code=str(login_resp.status_code),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
sso_fields = dict(
|
|
78
|
+
re.findall(
|
|
79
|
+
r'name="([^"]+)"\s+value="([^"]*)"',
|
|
80
|
+
login_resp.text,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
if "USERNAME" not in sso_fields:
|
|
84
|
+
raise AuthenticationError(
|
|
85
|
+
"Invalid credentials or unexpected login response",
|
|
86
|
+
error_code="LOGIN_FAILED",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
sso_resp = await http.post(
|
|
90
|
+
SSO_HANDLER_URL,
|
|
91
|
+
data=sso_fields,
|
|
92
|
+
headers={
|
|
93
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
94
|
+
"Origin": "https://www.mytnb.com.my",
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if sso_resp.status_code != 200:
|
|
99
|
+
raise AuthenticationError(
|
|
100
|
+
"SSO authentication failed",
|
|
101
|
+
error_code=str(sso_resp.status_code),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
user_id = None
|
|
105
|
+
user_name = email
|
|
106
|
+
display_name = ""
|
|
107
|
+
for cookie_header in sso_resp.headers.get_list("set-cookie"):
|
|
108
|
+
if "eyJhbGci" not in cookie_header:
|
|
109
|
+
continue
|
|
110
|
+
jwt_match = re.search(r"=(eyJ[^;]+)", cookie_header)
|
|
111
|
+
if not jwt_match:
|
|
112
|
+
continue
|
|
113
|
+
parts = jwt_match.group(1).split(".")
|
|
114
|
+
payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
|
115
|
+
jwt_data = json.loads(base64.b64decode(payload_b64))
|
|
116
|
+
ui = json.loads(jwt_data["UserInfo"])
|
|
117
|
+
user_id = ui["UserId"]
|
|
118
|
+
user_name = ui.get("UserName", email)
|
|
119
|
+
display_name = ui.get("DisplayName", "")
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
if not user_id:
|
|
123
|
+
raise AuthenticationError(
|
|
124
|
+
"Failed to extract user identity from SSO response",
|
|
125
|
+
error_code="SSO_PARSE_FAILED",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
token_resp = await http.post(
|
|
129
|
+
f"{REST_BASE_URL}/Identity/api/v1/Identity/GenerateAccessToken",
|
|
130
|
+
headers={
|
|
131
|
+
"Content-Type": "application/json",
|
|
132
|
+
"x-api-key": DEFAULT_API_KEY,
|
|
133
|
+
"Accept": "application/json",
|
|
134
|
+
},
|
|
135
|
+
params={"environment": "Prod"},
|
|
136
|
+
json={
|
|
137
|
+
"channel": "myTNB_API_Mobile",
|
|
138
|
+
"userId": user_id,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
token_resp.raise_for_status()
|
|
142
|
+
token_data = token_resp.json()
|
|
143
|
+
access_token = token_data["content"]["accessToken"]
|
|
144
|
+
|
|
145
|
+
secure_key = str(uuid.uuid4()).upper()
|
|
146
|
+
|
|
147
|
+
credentials = Credentials(
|
|
148
|
+
api_key=DEFAULT_API_KEY,
|
|
149
|
+
authorization_token=access_token,
|
|
150
|
+
secure_key=secure_key,
|
|
151
|
+
user_info=UserInfo(
|
|
152
|
+
user_name=user_name,
|
|
153
|
+
user_id=user_id,
|
|
154
|
+
display_name=display_name,
|
|
155
|
+
),
|
|
156
|
+
device_info=device_info,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return cls(credentials, timeout=timeout, use_staging_key=use_staging_key)
|