onecmd 0.2.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.
- onecmd/__init__.py +0 -0
- onecmd/auth/__init__.py +0 -0
- onecmd/auth/owner.py +45 -0
- onecmd/auth/totp.py +176 -0
- onecmd/bot/__init__.py +0 -0
- onecmd/bot/api.py +141 -0
- onecmd/bot/handler.py +427 -0
- onecmd/bot/poller.py +105 -0
- onecmd/config.py +153 -0
- onecmd/emoji.py +128 -0
- onecmd/main.py +112 -0
- onecmd/manager/__init__.py +0 -0
- onecmd/manager/agent.py +525 -0
- onecmd/manager/default_sop.md +109 -0
- onecmd/manager/llm.py +494 -0
- onecmd/manager/memory.py +99 -0
- onecmd/manager/queue.py +209 -0
- onecmd/manager/router.py +101 -0
- onecmd/manager/sop.py +92 -0
- onecmd/manager/tasks.py +300 -0
- onecmd/manager/tools.py +495 -0
- onecmd/store.py +121 -0
- onecmd/terminal/__init__.py +0 -0
- onecmd/terminal/backend.py +148 -0
- onecmd/terminal/display.py +169 -0
- onecmd/terminal/macos.py +636 -0
- onecmd/terminal/scope.py +98 -0
- onecmd/terminal/tmux.py +209 -0
- onecmd-0.2.0.dist-info/METADATA +251 -0
- onecmd-0.2.0.dist-info/RECORD +34 -0
- onecmd-0.2.0.dist-info/WHEEL +5 -0
- onecmd-0.2.0.dist-info/entry_points.txt +2 -0
- onecmd-0.2.0.dist-info/licenses/LICENSE +22 -0
- onecmd-0.2.0.dist-info/top_level.txt +1 -0
onecmd/__init__.py
ADDED
|
File without changes
|
onecmd/auth/__init__.py
ADDED
|
File without changes
|
onecmd/auth/owner.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Owner registration and verification.
|
|
2
|
+
|
|
3
|
+
Calling spec:
|
|
4
|
+
Inputs: Store instance, user_id (int)
|
|
5
|
+
Outputs: tuple[bool, bool] — (is_owner, just_registered)
|
|
6
|
+
Side effects: writes "owner_id" to store on first-ever call
|
|
7
|
+
|
|
8
|
+
Logic:
|
|
9
|
+
1. Read "owner_id" from store (O(1) SQLite lookup).
|
|
10
|
+
2. If no owner registered, register this user as owner.
|
|
11
|
+
Return (True, True).
|
|
12
|
+
3. If owner exists and matches user_id, return (True, False).
|
|
13
|
+
4. If owner exists and does NOT match, return (False, False).
|
|
14
|
+
|
|
15
|
+
Guarding:
|
|
16
|
+
- First user to message becomes owner (stored via store.set)
|
|
17
|
+
- No mechanism to change owner at runtime
|
|
18
|
+
- Owner check is O(1) lookup, not bypassable
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from onecmd.store import Store
|
|
24
|
+
|
|
25
|
+
OWNER_KEY = "owner_id"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_owner(store: Store, user_id: int) -> tuple[bool, bool]:
|
|
29
|
+
"""Check whether *user_id* is the bot owner.
|
|
30
|
+
|
|
31
|
+
Returns ``(is_owner, just_registered)``.
|
|
32
|
+
|
|
33
|
+
On the very first call (no owner in the store), the caller is
|
|
34
|
+
registered as owner and ``(True, True)`` is returned. All
|
|
35
|
+
subsequent calls compare against the stored owner ID.
|
|
36
|
+
"""
|
|
37
|
+
stored = store.get(OWNER_KEY)
|
|
38
|
+
|
|
39
|
+
# First user becomes owner.
|
|
40
|
+
if stored is None:
|
|
41
|
+
store.set(OWNER_KEY, str(user_id))
|
|
42
|
+
return True, True
|
|
43
|
+
|
|
44
|
+
is_owner = stored == str(user_id)
|
|
45
|
+
return is_owner, False
|
onecmd/auth/totp.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""TOTP authentication for onecmd.
|
|
2
|
+
|
|
3
|
+
Calling spec:
|
|
4
|
+
Inputs:
|
|
5
|
+
totp_setup(store, enable_otp, weak_security, otp_timeout) -> bool
|
|
6
|
+
totp_verify(code, secret_hex) -> bool
|
|
7
|
+
generate_secret() -> bytes (20 random bytes via os.urandom)
|
|
8
|
+
base32_encode(data) -> str (RFC 4648)
|
|
9
|
+
totp_code(secret, time_step) -> int (6-digit TOTP)
|
|
10
|
+
build_otpauth_uri(secret_b32, issuer, label) -> str
|
|
11
|
+
print_qr(uri) -> None (text QR to stdout)
|
|
12
|
+
is_timed_out(last_auth_time, timeout) -> bool
|
|
13
|
+
Outputs: bool (verified) or None (setup side effects)
|
|
14
|
+
Side effects: generates secret on first run, prints QR to terminal
|
|
15
|
+
|
|
16
|
+
Sealed (deterministic):
|
|
17
|
+
- HMAC-SHA1 based TOTP (RFC 6238), 30s steps, +/-1 tolerance
|
|
18
|
+
- QR code via qrcode library
|
|
19
|
+
|
|
20
|
+
Guarding:
|
|
21
|
+
- Secret from os.urandom(20), stored in SQLite, never logged
|
|
22
|
+
- OTP codes validated as exactly 6 digits before comparison
|
|
23
|
+
- Constant-time comparison via hmac.compare_digest
|
|
24
|
+
- Timeout enforced: re-auth required after otp_timeout seconds
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import hmac
|
|
31
|
+
import os
|
|
32
|
+
import struct
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
import qrcode # type: ignore[import-untyped]
|
|
36
|
+
|
|
37
|
+
TOTP_PERIOD = 30
|
|
38
|
+
TOTP_DIGITS = 6
|
|
39
|
+
SECRET_LENGTH = 20
|
|
40
|
+
TOTP_WINDOW = 1 # +/-1 time step tolerance
|
|
41
|
+
STORE_KEY = "totp_secret"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def generate_secret() -> bytes:
|
|
45
|
+
"""Return 20 cryptographically secure random bytes."""
|
|
46
|
+
return os.urandom(SECRET_LENGTH)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_B32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def base32_encode(data: bytes) -> str:
|
|
53
|
+
"""Encode *data* to Base32 (RFC 4648), no padding."""
|
|
54
|
+
buf = 0
|
|
55
|
+
bits = 0
|
|
56
|
+
out: list[str] = []
|
|
57
|
+
for byte in data:
|
|
58
|
+
buf = (buf << 8) | byte
|
|
59
|
+
bits += 8
|
|
60
|
+
while bits >= 5:
|
|
61
|
+
bits -= 5
|
|
62
|
+
out.append(_B32_ALPHABET[(buf >> bits) & 0x1F])
|
|
63
|
+
if bits > 0:
|
|
64
|
+
out.append(_B32_ALPHABET[(buf << (5 - bits)) & 0x1F])
|
|
65
|
+
return "".join(out)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def totp_code(secret: bytes, time_step: int) -> int:
|
|
69
|
+
"""Compute 6-digit TOTP from *secret* and *time_step*."""
|
|
70
|
+
msg = struct.pack(">Q", time_step)
|
|
71
|
+
digest = hmac.new(secret, msg, hashlib.sha1).digest()
|
|
72
|
+
offset = digest[19] & 0x0F
|
|
73
|
+
code = (
|
|
74
|
+
((digest[offset] & 0x7F) << 24)
|
|
75
|
+
| (digest[offset + 1] << 16)
|
|
76
|
+
| (digest[offset + 2] << 8)
|
|
77
|
+
| digest[offset + 3]
|
|
78
|
+
)
|
|
79
|
+
return code % (10**TOTP_DIGITS)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _bytes_to_hex(data: bytes) -> str:
|
|
83
|
+
return data.hex()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _hex_to_bytes(hex_str: str) -> bytes:
|
|
87
|
+
return bytes.fromhex(hex_str)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_otpauth_uri(
|
|
91
|
+
secret_b32: str,
|
|
92
|
+
issuer: str = "tgterm",
|
|
93
|
+
label: str = "tgterm",
|
|
94
|
+
) -> str:
|
|
95
|
+
"""Build an otpauth:// URI for TOTP QR code generation."""
|
|
96
|
+
return f"otpauth://totp/{label}?secret={secret_b32}&issuer={issuer}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def print_qr(uri: str) -> None:
|
|
100
|
+
"""Print a text-mode QR code for *uri* to stdout."""
|
|
101
|
+
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
|
|
102
|
+
qr.add_data(uri)
|
|
103
|
+
qr.make(fit=True)
|
|
104
|
+
qr.print_ascii()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def is_timed_out(last_auth_time: float, timeout: int) -> bool:
|
|
108
|
+
"""Return True if re-authentication is needed."""
|
|
109
|
+
return (time.time() - last_auth_time) >= timeout
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def totp_verify(code: str, secret_hex: str) -> bool:
|
|
113
|
+
"""Verify a TOTP *code* against *secret_hex*.
|
|
114
|
+
|
|
115
|
+
Returns True only when *code* is a valid 6-digit string matching
|
|
116
|
+
the current time step or +/-1. Constant-time via hmac.compare_digest.
|
|
117
|
+
"""
|
|
118
|
+
if not isinstance(code, str) or len(code) != TOTP_DIGITS:
|
|
119
|
+
return False
|
|
120
|
+
if not code.isdigit():
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
secret = _hex_to_bytes(secret_hex)
|
|
124
|
+
if len(secret) != SECRET_LENGTH:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
now_step = int(time.time()) // TOTP_PERIOD
|
|
128
|
+
|
|
129
|
+
for offset in range(-TOTP_WINDOW, TOTP_WINDOW + 1):
|
|
130
|
+
expected = totp_code(secret, now_step + offset)
|
|
131
|
+
expected_str = f"{expected:06d}"
|
|
132
|
+
if hmac.compare_digest(code, expected_str):
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def totp_setup(
|
|
139
|
+
store: object,
|
|
140
|
+
enable_otp: bool,
|
|
141
|
+
weak_security: bool,
|
|
142
|
+
otp_timeout: int = 300,
|
|
143
|
+
) -> bool:
|
|
144
|
+
"""Set up TOTP authentication.
|
|
145
|
+
|
|
146
|
+
*store* must have get(key)->str|None and set(key, value) methods.
|
|
147
|
+
Returns True if OTP is active, False if disabled.
|
|
148
|
+
"""
|
|
149
|
+
if weak_security:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
if not enable_otp:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
existing = store.get(STORE_KEY) # type: ignore[union-attr]
|
|
156
|
+
if existing:
|
|
157
|
+
timeout_str = store.get("otp_timeout") # type: ignore[union-attr]
|
|
158
|
+
if timeout_str:
|
|
159
|
+
t = int(timeout_str)
|
|
160
|
+
if 30 <= t <= 28800:
|
|
161
|
+
pass # caller reads back; we validate bounds
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
secret = generate_secret()
|
|
165
|
+
store.set(STORE_KEY, _bytes_to_hex(secret)) # type: ignore[union-attr]
|
|
166
|
+
|
|
167
|
+
b32 = base32_encode(secret)
|
|
168
|
+
uri = build_otpauth_uri(b32)
|
|
169
|
+
|
|
170
|
+
print("\n=== TOTP Setup ===")
|
|
171
|
+
print("Scan this QR code with Google Authenticator:\n")
|
|
172
|
+
print_qr(uri)
|
|
173
|
+
print(f"\nOr enter this secret manually: {b32}")
|
|
174
|
+
print("==================\n")
|
|
175
|
+
|
|
176
|
+
return True
|
onecmd/bot/__init__.py
ADDED
|
File without changes
|
onecmd/bot/api.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Telegram message helper functions.
|
|
2
|
+
|
|
3
|
+
Calling spec:
|
|
4
|
+
Inputs: bot (telegram.Bot instance), chat_id (int), text/msg_id/callback_id
|
|
5
|
+
Outputs: msg_id (int) or bool; None/False on failure
|
|
6
|
+
Side effects: Telegram API calls (send, edit, delete, answer callback)
|
|
7
|
+
|
|
8
|
+
Functions:
|
|
9
|
+
html_escape(text) -> str
|
|
10
|
+
Escapes <, >, & for Telegram HTML.
|
|
11
|
+
|
|
12
|
+
send_message(bot, chat_id, text, reply_markup=None, parse_mode="HTML") -> int | None
|
|
13
|
+
Send a text message. Returns message_id on success, None on failure.
|
|
14
|
+
|
|
15
|
+
edit_message(bot, chat_id, msg_id, text, parse_mode="HTML") -> bool
|
|
16
|
+
Edit an existing message. Returns True on success, False on failure.
|
|
17
|
+
|
|
18
|
+
delete_message(bot, chat_id, msg_id) -> bool
|
|
19
|
+
Delete a message. Returns True on success, False on failure.
|
|
20
|
+
|
|
21
|
+
answer_callback(bot, callback_id) -> bool
|
|
22
|
+
Acknowledge a callback query. Returns True on success, False on failure.
|
|
23
|
+
|
|
24
|
+
Guarding:
|
|
25
|
+
- Text length capped at 4096 chars (Telegram limit), truncated with "..."
|
|
26
|
+
- HTML-escapes user content in <pre> blocks (escape <, >, &)
|
|
27
|
+
- chat_id validated as integer
|
|
28
|
+
- All functions handle telegram.error.TelegramError gracefully (log + return None/False)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
from typing import TYPE_CHECKING
|
|
35
|
+
|
|
36
|
+
from telegram.error import TelegramError
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from telegram import Bot
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
MAX_TEXT_LENGTH = 4096
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def html_escape(text: str) -> str:
|
|
47
|
+
"""Escape <, >, & for Telegram HTML messages."""
|
|
48
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _truncate(text: str) -> str:
|
|
52
|
+
"""Truncate text to Telegram's 4096-char limit, adding '...' if truncated."""
|
|
53
|
+
if len(text) <= MAX_TEXT_LENGTH:
|
|
54
|
+
return text
|
|
55
|
+
return text[: MAX_TEXT_LENGTH - 3] + "..."
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validate_chat_id(chat_id: int) -> int:
|
|
59
|
+
"""Validate chat_id is an integer. Raises TypeError if not."""
|
|
60
|
+
if not isinstance(chat_id, int):
|
|
61
|
+
raise TypeError(f"chat_id must be int, got {type(chat_id).__name__}")
|
|
62
|
+
return chat_id
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def send_message(
|
|
66
|
+
bot: Bot,
|
|
67
|
+
chat_id: int,
|
|
68
|
+
text: str,
|
|
69
|
+
reply_markup: object | None = None,
|
|
70
|
+
parse_mode: str = "HTML",
|
|
71
|
+
) -> int | None:
|
|
72
|
+
"""Send a message via Telegram. Returns message_id or None on failure."""
|
|
73
|
+
try:
|
|
74
|
+
_validate_chat_id(chat_id)
|
|
75
|
+
text = _truncate(text)
|
|
76
|
+
msg = bot.send_message(
|
|
77
|
+
chat_id=chat_id,
|
|
78
|
+
text=text,
|
|
79
|
+
parse_mode=parse_mode,
|
|
80
|
+
reply_markup=reply_markup,
|
|
81
|
+
disable_web_page_preview=True,
|
|
82
|
+
)
|
|
83
|
+
return msg.message_id
|
|
84
|
+
except TypeError:
|
|
85
|
+
raise
|
|
86
|
+
except TelegramError as exc:
|
|
87
|
+
logger.error("send_message failed chat_id=%s: %s", chat_id, exc)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def edit_message(
|
|
92
|
+
bot: Bot,
|
|
93
|
+
chat_id: int,
|
|
94
|
+
msg_id: int,
|
|
95
|
+
text: str,
|
|
96
|
+
parse_mode: str = "HTML",
|
|
97
|
+
) -> bool:
|
|
98
|
+
"""Edit an existing message. Returns True on success, False on failure."""
|
|
99
|
+
try:
|
|
100
|
+
_validate_chat_id(chat_id)
|
|
101
|
+
text = _truncate(text)
|
|
102
|
+
bot.edit_message_text(
|
|
103
|
+
chat_id=chat_id,
|
|
104
|
+
message_id=msg_id,
|
|
105
|
+
text=text,
|
|
106
|
+
parse_mode=parse_mode,
|
|
107
|
+
disable_web_page_preview=True,
|
|
108
|
+
)
|
|
109
|
+
return True
|
|
110
|
+
except TypeError:
|
|
111
|
+
raise
|
|
112
|
+
except TelegramError as exc:
|
|
113
|
+
logger.error(
|
|
114
|
+
"edit_message failed chat_id=%s msg_id=%s: %s", chat_id, msg_id, exc
|
|
115
|
+
)
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def delete_message(bot: Bot, chat_id: int, msg_id: int) -> bool:
|
|
120
|
+
"""Delete a message. Returns True on success, False on failure."""
|
|
121
|
+
try:
|
|
122
|
+
_validate_chat_id(chat_id)
|
|
123
|
+
bot.delete_message(chat_id=chat_id, message_id=msg_id)
|
|
124
|
+
return True
|
|
125
|
+
except TypeError:
|
|
126
|
+
raise
|
|
127
|
+
except TelegramError as exc:
|
|
128
|
+
logger.error(
|
|
129
|
+
"delete_message failed chat_id=%s msg_id=%s: %s", chat_id, msg_id, exc
|
|
130
|
+
)
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def answer_callback(bot: Bot, callback_id: str) -> bool:
|
|
135
|
+
"""Acknowledge a callback query. Returns True on success, False on failure."""
|
|
136
|
+
try:
|
|
137
|
+
bot.answer_callback_query(callback_query_id=callback_id)
|
|
138
|
+
return True
|
|
139
|
+
except TelegramError as exc:
|
|
140
|
+
logger.error("answer_callback failed callback_id=%s: %s", callback_id, exc)
|
|
141
|
+
return False
|