yee88 0.3.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.
- yee88/__init__.py +1 -0
- yee88/api.py +116 -0
- yee88/backends.py +25 -0
- yee88/backends_helpers.py +14 -0
- yee88/cli/__init__.py +228 -0
- yee88/cli/config.py +320 -0
- yee88/cli/doctor.py +173 -0
- yee88/cli/init.py +113 -0
- yee88/cli/onboarding_cmd.py +126 -0
- yee88/cli/plugins.py +196 -0
- yee88/cli/run.py +419 -0
- yee88/cli/topic.py +355 -0
- yee88/commands.py +134 -0
- yee88/config.py +142 -0
- yee88/config_migrations.py +124 -0
- yee88/config_watch.py +146 -0
- yee88/context.py +9 -0
- yee88/directives.py +146 -0
- yee88/engines.py +53 -0
- yee88/events.py +170 -0
- yee88/ids.py +17 -0
- yee88/lockfile.py +158 -0
- yee88/logging.py +283 -0
- yee88/markdown.py +298 -0
- yee88/model.py +77 -0
- yee88/plugins.py +312 -0
- yee88/presenter.py +25 -0
- yee88/progress.py +99 -0
- yee88/router.py +113 -0
- yee88/runner.py +712 -0
- yee88/runner_bridge.py +619 -0
- yee88/runners/__init__.py +1 -0
- yee88/runners/claude.py +483 -0
- yee88/runners/codex.py +656 -0
- yee88/runners/mock.py +221 -0
- yee88/runners/opencode.py +505 -0
- yee88/runners/pi.py +523 -0
- yee88/runners/run_options.py +39 -0
- yee88/runners/tool_actions.py +90 -0
- yee88/runtime_loader.py +207 -0
- yee88/scheduler.py +159 -0
- yee88/schemas/__init__.py +1 -0
- yee88/schemas/claude.py +238 -0
- yee88/schemas/codex.py +169 -0
- yee88/schemas/opencode.py +51 -0
- yee88/schemas/pi.py +117 -0
- yee88/settings.py +360 -0
- yee88/telegram/__init__.py +20 -0
- yee88/telegram/api_models.py +37 -0
- yee88/telegram/api_schemas.py +152 -0
- yee88/telegram/backend.py +163 -0
- yee88/telegram/bridge.py +425 -0
- yee88/telegram/chat_prefs.py +242 -0
- yee88/telegram/chat_sessions.py +112 -0
- yee88/telegram/client.py +409 -0
- yee88/telegram/client_api.py +539 -0
- yee88/telegram/commands/__init__.py +12 -0
- yee88/telegram/commands/agent.py +196 -0
- yee88/telegram/commands/cancel.py +116 -0
- yee88/telegram/commands/dispatch.py +111 -0
- yee88/telegram/commands/executor.py +449 -0
- yee88/telegram/commands/file_transfer.py +586 -0
- yee88/telegram/commands/handlers.py +45 -0
- yee88/telegram/commands/media.py +143 -0
- yee88/telegram/commands/menu.py +139 -0
- yee88/telegram/commands/model.py +215 -0
- yee88/telegram/commands/overrides.py +159 -0
- yee88/telegram/commands/parse.py +30 -0
- yee88/telegram/commands/plan.py +16 -0
- yee88/telegram/commands/reasoning.py +234 -0
- yee88/telegram/commands/reply.py +23 -0
- yee88/telegram/commands/topics.py +332 -0
- yee88/telegram/commands/trigger.py +143 -0
- yee88/telegram/context.py +140 -0
- yee88/telegram/engine_defaults.py +86 -0
- yee88/telegram/engine_overrides.py +105 -0
- yee88/telegram/files.py +178 -0
- yee88/telegram/loop.py +1822 -0
- yee88/telegram/onboarding.py +1088 -0
- yee88/telegram/outbox.py +177 -0
- yee88/telegram/parsing.py +239 -0
- yee88/telegram/render.py +198 -0
- yee88/telegram/state_store.py +88 -0
- yee88/telegram/topic_state.py +334 -0
- yee88/telegram/topics.py +256 -0
- yee88/telegram/trigger_mode.py +68 -0
- yee88/telegram/types.py +63 -0
- yee88/telegram/voice.py +110 -0
- yee88/transport.py +53 -0
- yee88/transport_runtime.py +323 -0
- yee88/transports.py +76 -0
- yee88/utils/__init__.py +1 -0
- yee88/utils/git.py +87 -0
- yee88/utils/json_state.py +21 -0
- yee88/utils/paths.py +47 -0
- yee88/utils/streams.py +44 -0
- yee88/utils/subprocess.py +86 -0
- yee88/worktrees.py +135 -0
- yee88-0.3.0.dist-info/METADATA +116 -0
- yee88-0.3.0.dist-info/RECORD +103 -0
- yee88-0.3.0.dist-info/WHEEL +4 -0
- yee88-0.3.0.dist-info/entry_points.txt +11 -0
- yee88-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..chat_prefs import ChatPrefsStore
|
|
6
|
+
from ..files import split_command_args
|
|
7
|
+
from ..topic_state import TopicStateStore
|
|
8
|
+
from ..topics import _topic_key
|
|
9
|
+
from ..trigger_mode import resolve_trigger_mode
|
|
10
|
+
from ..types import TelegramIncomingMessage
|
|
11
|
+
from .overrides import check_admin_or_private
|
|
12
|
+
from .plan import ActionPlan
|
|
13
|
+
from .reply import make_reply
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ..bridge import TelegramBridgeConfig
|
|
17
|
+
|
|
18
|
+
TRIGGER_USAGE = (
|
|
19
|
+
"usage: `/trigger`, `/trigger all`, `/trigger mentions`, or `/trigger clear`"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def _handle_trigger_command(
|
|
24
|
+
cfg: TelegramBridgeConfig,
|
|
25
|
+
msg: TelegramIncomingMessage,
|
|
26
|
+
args_text: str,
|
|
27
|
+
_ambient_context,
|
|
28
|
+
topic_store: TopicStateStore | None,
|
|
29
|
+
chat_prefs: ChatPrefsStore | None,
|
|
30
|
+
*,
|
|
31
|
+
resolved_scope: str | None = None,
|
|
32
|
+
scope_chat_ids: frozenset[int] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
reply = make_reply(cfg, msg)
|
|
35
|
+
plan = await _plan_trigger_command(
|
|
36
|
+
cfg,
|
|
37
|
+
msg,
|
|
38
|
+
args_text=args_text,
|
|
39
|
+
topic_store=topic_store,
|
|
40
|
+
chat_prefs=chat_prefs,
|
|
41
|
+
scope_chat_ids=scope_chat_ids,
|
|
42
|
+
)
|
|
43
|
+
await plan.execute(reply)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _plan_trigger_command(
|
|
47
|
+
cfg: TelegramBridgeConfig,
|
|
48
|
+
msg: TelegramIncomingMessage,
|
|
49
|
+
*,
|
|
50
|
+
args_text: str,
|
|
51
|
+
topic_store: TopicStateStore | None,
|
|
52
|
+
chat_prefs: ChatPrefsStore | None,
|
|
53
|
+
scope_chat_ids: frozenset[int] | None,
|
|
54
|
+
) -> ActionPlan:
|
|
55
|
+
tkey = _topic_key(msg, cfg, scope_chat_ids=scope_chat_ids)
|
|
56
|
+
tokens = split_command_args(args_text)
|
|
57
|
+
action = tokens[0].lower() if tokens else "show"
|
|
58
|
+
|
|
59
|
+
if action in {"show", ""}:
|
|
60
|
+
resolved = await resolve_trigger_mode(
|
|
61
|
+
chat_id=msg.chat_id,
|
|
62
|
+
thread_id=msg.thread_id,
|
|
63
|
+
chat_prefs=chat_prefs,
|
|
64
|
+
topic_store=topic_store,
|
|
65
|
+
)
|
|
66
|
+
topic_mode = None
|
|
67
|
+
if tkey is not None and topic_store is not None:
|
|
68
|
+
topic_mode = await topic_store.get_trigger_mode(tkey[0], tkey[1])
|
|
69
|
+
chat_mode = None
|
|
70
|
+
if chat_prefs is not None:
|
|
71
|
+
chat_mode = await chat_prefs.get_trigger_mode(msg.chat_id)
|
|
72
|
+
if topic_mode is not None:
|
|
73
|
+
source = "topic override"
|
|
74
|
+
elif chat_mode is not None:
|
|
75
|
+
source = "chat default"
|
|
76
|
+
else:
|
|
77
|
+
source = "default"
|
|
78
|
+
trigger_line = f"trigger: {resolved} ({source})"
|
|
79
|
+
topic_label = topic_mode or "none"
|
|
80
|
+
if tkey is None:
|
|
81
|
+
topic_label = "none"
|
|
82
|
+
chat_label = "unavailable" if chat_prefs is None else chat_mode or "none"
|
|
83
|
+
defaults_line = f"defaults: topic: {topic_label}, chat: {chat_label}"
|
|
84
|
+
available_line = "available: all, mentions"
|
|
85
|
+
return ActionPlan(
|
|
86
|
+
reply_text="\n\n".join([trigger_line, defaults_line, available_line])
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if action in {"all", "mentions"}:
|
|
90
|
+
decision = await check_admin_or_private(
|
|
91
|
+
cfg,
|
|
92
|
+
msg,
|
|
93
|
+
missing_sender="cannot verify sender for trigger settings.",
|
|
94
|
+
failed_member="failed to verify trigger permissions.",
|
|
95
|
+
denied="changing trigger mode is restricted to group admins.",
|
|
96
|
+
)
|
|
97
|
+
if not decision.allowed:
|
|
98
|
+
return ActionPlan(reply_text=decision.error_text or TRIGGER_USAGE)
|
|
99
|
+
if tkey is not None:
|
|
100
|
+
if topic_store is None:
|
|
101
|
+
return ActionPlan(reply_text="topic trigger settings are unavailable.")
|
|
102
|
+
return ActionPlan(
|
|
103
|
+
reply_text=f"topic trigger mode set to `{action}`",
|
|
104
|
+
actions=(
|
|
105
|
+
lambda: topic_store.set_trigger_mode(tkey[0], tkey[1], action),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
if chat_prefs is None:
|
|
109
|
+
return ActionPlan(
|
|
110
|
+
reply_text="chat trigger settings are unavailable (no config path)."
|
|
111
|
+
)
|
|
112
|
+
return ActionPlan(
|
|
113
|
+
reply_text=f"chat trigger mode set to `{action}`",
|
|
114
|
+
actions=(lambda: chat_prefs.set_trigger_mode(msg.chat_id, action),),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if action == "clear":
|
|
118
|
+
decision = await check_admin_or_private(
|
|
119
|
+
cfg,
|
|
120
|
+
msg,
|
|
121
|
+
missing_sender="cannot verify sender for trigger settings.",
|
|
122
|
+
failed_member="failed to verify trigger permissions.",
|
|
123
|
+
denied="changing trigger mode is restricted to group admins.",
|
|
124
|
+
)
|
|
125
|
+
if not decision.allowed:
|
|
126
|
+
return ActionPlan(reply_text=decision.error_text or TRIGGER_USAGE)
|
|
127
|
+
if tkey is not None:
|
|
128
|
+
if topic_store is None:
|
|
129
|
+
return ActionPlan(reply_text="topic trigger settings are unavailable.")
|
|
130
|
+
return ActionPlan(
|
|
131
|
+
reply_text="topic trigger mode cleared (using chat default).",
|
|
132
|
+
actions=(lambda: topic_store.clear_trigger_mode(tkey[0], tkey[1]),),
|
|
133
|
+
)
|
|
134
|
+
if chat_prefs is None:
|
|
135
|
+
return ActionPlan(
|
|
136
|
+
reply_text="chat trigger settings are unavailable (no config path)."
|
|
137
|
+
)
|
|
138
|
+
return ActionPlan(
|
|
139
|
+
reply_text="chat trigger mode reset to `all`.",
|
|
140
|
+
actions=(lambda: chat_prefs.clear_trigger_mode(msg.chat_id),),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return ActionPlan(reply_text=TRIGGER_USAGE)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..context import RunContext
|
|
6
|
+
from ..transport_runtime import TransportRuntime
|
|
7
|
+
from .topic_state import TopicThreadSnapshot
|
|
8
|
+
from .topics import _topics_scope_label
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .bridge import TelegramBridgeConfig
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"_format_context",
|
|
15
|
+
"_format_ctx_status",
|
|
16
|
+
"_merge_topic_context",
|
|
17
|
+
"_parse_project_branch_args",
|
|
18
|
+
"_usage_ctx_set",
|
|
19
|
+
"_usage_topic",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _format_context(runtime: TransportRuntime, context: RunContext | None) -> str:
|
|
24
|
+
if context is None or context.project is None:
|
|
25
|
+
return "none"
|
|
26
|
+
project = runtime.project_alias_for_key(context.project)
|
|
27
|
+
if context.branch:
|
|
28
|
+
return f"{project} @{context.branch}"
|
|
29
|
+
return project
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _usage_ctx_set(*, chat_project: str | None) -> str:
|
|
33
|
+
if chat_project is not None:
|
|
34
|
+
return "usage: `/ctx set [@branch]`"
|
|
35
|
+
return "usage: `/ctx set <project> [@branch]`"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _usage_topic(*, chat_project: str | None) -> str:
|
|
39
|
+
if chat_project is not None:
|
|
40
|
+
return "usage: `/topic @branch`"
|
|
41
|
+
return "usage: `/topic <project> @branch`"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_project_branch_args(
|
|
45
|
+
args_text: str,
|
|
46
|
+
*,
|
|
47
|
+
runtime: TransportRuntime,
|
|
48
|
+
require_branch: bool,
|
|
49
|
+
chat_project: str | None,
|
|
50
|
+
) -> tuple[RunContext | None, str | None]:
|
|
51
|
+
from .files import split_command_args
|
|
52
|
+
|
|
53
|
+
tokens = split_command_args(args_text)
|
|
54
|
+
if not tokens:
|
|
55
|
+
return (
|
|
56
|
+
None,
|
|
57
|
+
_usage_topic(chat_project=chat_project)
|
|
58
|
+
if require_branch
|
|
59
|
+
else _usage_ctx_set(chat_project=chat_project),
|
|
60
|
+
)
|
|
61
|
+
if len(tokens) > 2:
|
|
62
|
+
return None, "too many arguments"
|
|
63
|
+
project_token: str | None = None
|
|
64
|
+
branch: str | None = None
|
|
65
|
+
first = tokens[0]
|
|
66
|
+
if first.startswith("@"):
|
|
67
|
+
branch = first[1:] or None
|
|
68
|
+
else:
|
|
69
|
+
project_token = first
|
|
70
|
+
if len(tokens) == 2:
|
|
71
|
+
second = tokens[1]
|
|
72
|
+
if not second.startswith("@"):
|
|
73
|
+
return None, "branch must be prefixed with @"
|
|
74
|
+
branch = second[1:] or None
|
|
75
|
+
|
|
76
|
+
project_key: str | None = None
|
|
77
|
+
if chat_project is not None:
|
|
78
|
+
if project_token is None:
|
|
79
|
+
project_key = chat_project
|
|
80
|
+
else:
|
|
81
|
+
normalized = runtime.normalize_project_key(project_token)
|
|
82
|
+
if normalized is None:
|
|
83
|
+
return None, f"unknown project {project_token!r}"
|
|
84
|
+
if normalized != chat_project:
|
|
85
|
+
expected = runtime.project_alias_for_key(chat_project)
|
|
86
|
+
return None, (f"project mismatch for this chat; expected {expected!r}.")
|
|
87
|
+
project_key = normalized
|
|
88
|
+
else:
|
|
89
|
+
if project_token is None:
|
|
90
|
+
return None, "project is required"
|
|
91
|
+
project_key = runtime.normalize_project_key(project_token)
|
|
92
|
+
if project_key is None:
|
|
93
|
+
return None, f"unknown project {project_token!r}"
|
|
94
|
+
|
|
95
|
+
if require_branch and not branch:
|
|
96
|
+
return None, "branch is required"
|
|
97
|
+
|
|
98
|
+
return RunContext(project=project_key, branch=branch), None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _format_ctx_status(
|
|
102
|
+
*,
|
|
103
|
+
cfg: TelegramBridgeConfig,
|
|
104
|
+
runtime: TransportRuntime,
|
|
105
|
+
bound: RunContext | None,
|
|
106
|
+
resolved: RunContext | None,
|
|
107
|
+
context_source: str,
|
|
108
|
+
snapshot: TopicThreadSnapshot | None,
|
|
109
|
+
chat_project: str | None,
|
|
110
|
+
) -> str:
|
|
111
|
+
lines = [
|
|
112
|
+
f"topics: enabled (scope={_topics_scope_label(cfg)})",
|
|
113
|
+
f"bound ctx: {_format_context(runtime, bound)}",
|
|
114
|
+
f"resolved ctx: {_format_context(runtime, resolved)} (source: {context_source})",
|
|
115
|
+
]
|
|
116
|
+
if chat_project is None and bound is None:
|
|
117
|
+
topic_usage = (
|
|
118
|
+
_usage_topic(chat_project=chat_project).removeprefix("usage: ").strip()
|
|
119
|
+
)
|
|
120
|
+
ctx_usage = (
|
|
121
|
+
_usage_ctx_set(chat_project=chat_project).removeprefix("usage: ").strip()
|
|
122
|
+
)
|
|
123
|
+
lines.append(f"note: unbound topic — bind with {topic_usage} or {ctx_usage}")
|
|
124
|
+
sessions = None
|
|
125
|
+
if snapshot is not None and snapshot.sessions:
|
|
126
|
+
sessions = ", ".join(sorted(snapshot.sessions))
|
|
127
|
+
lines.append(f"sessions: {sessions or 'none'}")
|
|
128
|
+
return "\n".join(lines)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _merge_topic_context(
|
|
132
|
+
*, chat_project: str | None, bound: RunContext | None
|
|
133
|
+
) -> RunContext | None:
|
|
134
|
+
if chat_project is None:
|
|
135
|
+
return bound
|
|
136
|
+
if bound is None:
|
|
137
|
+
return RunContext(project=chat_project, branch=None)
|
|
138
|
+
if bound.project is None:
|
|
139
|
+
return RunContext(project=chat_project, branch=bound.branch)
|
|
140
|
+
return bound
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from ..context import RunContext
|
|
7
|
+
from ..model import EngineId
|
|
8
|
+
from ..transport_runtime import TransportRuntime
|
|
9
|
+
from .chat_prefs import ChatPrefsStore
|
|
10
|
+
from .topic_state import TopicStateStore
|
|
11
|
+
|
|
12
|
+
EngineSource = Literal[
|
|
13
|
+
"directive",
|
|
14
|
+
"topic_default",
|
|
15
|
+
"chat_default",
|
|
16
|
+
"project_default",
|
|
17
|
+
"global_default",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class EngineResolution:
|
|
23
|
+
engine: EngineId
|
|
24
|
+
source: EngineSource
|
|
25
|
+
topic_default: EngineId | None
|
|
26
|
+
chat_default: EngineId | None
|
|
27
|
+
project_default: EngineId | None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def resolve_engine_for_message(
|
|
31
|
+
*,
|
|
32
|
+
runtime: TransportRuntime,
|
|
33
|
+
context: RunContext | None,
|
|
34
|
+
explicit_engine: EngineId | None,
|
|
35
|
+
chat_id: int,
|
|
36
|
+
topic_key: tuple[int, int] | None,
|
|
37
|
+
topic_store: TopicStateStore | None,
|
|
38
|
+
chat_prefs: ChatPrefsStore | None,
|
|
39
|
+
) -> EngineResolution:
|
|
40
|
+
topic_default = None
|
|
41
|
+
if topic_store is not None and topic_key is not None:
|
|
42
|
+
topic_default = await topic_store.get_default_engine(*topic_key)
|
|
43
|
+
chat_default = None
|
|
44
|
+
if chat_prefs is not None:
|
|
45
|
+
chat_default = await chat_prefs.get_default_engine(chat_id)
|
|
46
|
+
project_default = runtime.project_default_engine(context)
|
|
47
|
+
|
|
48
|
+
if explicit_engine is not None:
|
|
49
|
+
return EngineResolution(
|
|
50
|
+
engine=explicit_engine,
|
|
51
|
+
source="directive",
|
|
52
|
+
topic_default=topic_default,
|
|
53
|
+
chat_default=chat_default,
|
|
54
|
+
project_default=project_default,
|
|
55
|
+
)
|
|
56
|
+
if topic_default is not None:
|
|
57
|
+
return EngineResolution(
|
|
58
|
+
engine=topic_default,
|
|
59
|
+
source="topic_default",
|
|
60
|
+
topic_default=topic_default,
|
|
61
|
+
chat_default=chat_default,
|
|
62
|
+
project_default=project_default,
|
|
63
|
+
)
|
|
64
|
+
if chat_default is not None:
|
|
65
|
+
return EngineResolution(
|
|
66
|
+
engine=chat_default,
|
|
67
|
+
source="chat_default",
|
|
68
|
+
topic_default=topic_default,
|
|
69
|
+
chat_default=chat_default,
|
|
70
|
+
project_default=project_default,
|
|
71
|
+
)
|
|
72
|
+
if project_default is not None:
|
|
73
|
+
return EngineResolution(
|
|
74
|
+
engine=project_default,
|
|
75
|
+
source="project_default",
|
|
76
|
+
topic_default=topic_default,
|
|
77
|
+
chat_default=chat_default,
|
|
78
|
+
project_default=project_default,
|
|
79
|
+
)
|
|
80
|
+
return EngineResolution(
|
|
81
|
+
engine=runtime.default_engine,
|
|
82
|
+
source="global_default",
|
|
83
|
+
topic_default=topic_default,
|
|
84
|
+
chat_default=chat_default,
|
|
85
|
+
project_default=project_default,
|
|
86
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import msgspec
|
|
7
|
+
|
|
8
|
+
OverrideSource = Literal["topic_override", "chat_default", "default"]
|
|
9
|
+
|
|
10
|
+
REASONING_LEVELS: tuple[str, ...] = ("minimal", "low", "medium", "high", "xhigh")
|
|
11
|
+
REASONING_SUPPORTED_ENGINES = frozenset({"codex"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EngineOverrides(msgspec.Struct, forbid_unknown_fields=False):
|
|
15
|
+
model: str | None = None
|
|
16
|
+
reasoning: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class OverrideValueResolution:
|
|
21
|
+
value: str | None
|
|
22
|
+
source: OverrideSource
|
|
23
|
+
topic_value: str | None
|
|
24
|
+
chat_value: str | None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_override_value(value: str | None) -> str | None:
|
|
28
|
+
if value is None:
|
|
29
|
+
return None
|
|
30
|
+
cleaned = value.strip()
|
|
31
|
+
return cleaned or None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_overrides(overrides: EngineOverrides | None) -> EngineOverrides | None:
|
|
35
|
+
if overrides is None:
|
|
36
|
+
return None
|
|
37
|
+
model = normalize_override_value(overrides.model)
|
|
38
|
+
reasoning = normalize_override_value(overrides.reasoning)
|
|
39
|
+
if model is None and reasoning is None:
|
|
40
|
+
return None
|
|
41
|
+
return EngineOverrides(model=model, reasoning=reasoning)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def merge_overrides(
|
|
45
|
+
topic_override: EngineOverrides | None,
|
|
46
|
+
chat_override: EngineOverrides | None,
|
|
47
|
+
) -> EngineOverrides | None:
|
|
48
|
+
topic = normalize_overrides(topic_override)
|
|
49
|
+
chat = normalize_overrides(chat_override)
|
|
50
|
+
if topic is None and chat is None:
|
|
51
|
+
return None
|
|
52
|
+
model = None
|
|
53
|
+
reasoning = None
|
|
54
|
+
if topic is not None and topic.model is not None:
|
|
55
|
+
model = topic.model
|
|
56
|
+
elif chat is not None:
|
|
57
|
+
model = chat.model
|
|
58
|
+
if topic is not None and topic.reasoning is not None:
|
|
59
|
+
reasoning = topic.reasoning
|
|
60
|
+
elif chat is not None:
|
|
61
|
+
reasoning = chat.reasoning
|
|
62
|
+
return normalize_overrides(EngineOverrides(model=model, reasoning=reasoning))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_override_value(
|
|
66
|
+
*,
|
|
67
|
+
topic_override: EngineOverrides | None,
|
|
68
|
+
chat_override: EngineOverrides | None,
|
|
69
|
+
field: Literal["model", "reasoning"],
|
|
70
|
+
) -> OverrideValueResolution:
|
|
71
|
+
topic_value = normalize_override_value(
|
|
72
|
+
getattr(topic_override, field, None) if topic_override is not None else None
|
|
73
|
+
)
|
|
74
|
+
chat_value = normalize_override_value(
|
|
75
|
+
getattr(chat_override, field, None) if chat_override is not None else None
|
|
76
|
+
)
|
|
77
|
+
if topic_value is not None:
|
|
78
|
+
return OverrideValueResolution(
|
|
79
|
+
value=topic_value,
|
|
80
|
+
source="topic_override",
|
|
81
|
+
topic_value=topic_value,
|
|
82
|
+
chat_value=chat_value,
|
|
83
|
+
)
|
|
84
|
+
if chat_value is not None:
|
|
85
|
+
return OverrideValueResolution(
|
|
86
|
+
value=chat_value,
|
|
87
|
+
source="chat_default",
|
|
88
|
+
topic_value=topic_value,
|
|
89
|
+
chat_value=chat_value,
|
|
90
|
+
)
|
|
91
|
+
return OverrideValueResolution(
|
|
92
|
+
value=None,
|
|
93
|
+
source="default",
|
|
94
|
+
topic_value=topic_value,
|
|
95
|
+
chat_value=chat_value,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def allowed_reasoning_levels(engine: str) -> tuple[str, ...]:
|
|
100
|
+
_ = engine
|
|
101
|
+
return REASONING_LEVELS
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def supports_reasoning(engine: str) -> bool:
|
|
105
|
+
return engine in REASONING_SUPPORTED_ENGINES
|
yee88/telegram/files.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import tempfile
|
|
7
|
+
import zipfile
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ZipTooLargeError",
|
|
13
|
+
"default_upload_name",
|
|
14
|
+
"default_upload_path",
|
|
15
|
+
"deny_reason",
|
|
16
|
+
"file_usage",
|
|
17
|
+
"format_bytes",
|
|
18
|
+
"normalize_relative_path",
|
|
19
|
+
"parse_file_command",
|
|
20
|
+
"parse_file_prompt",
|
|
21
|
+
"resolve_path_within_root",
|
|
22
|
+
"split_command_args",
|
|
23
|
+
"write_bytes_atomic",
|
|
24
|
+
"zip_directory",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def split_command_args(text: str) -> tuple[str, ...]:
|
|
29
|
+
if not text.strip():
|
|
30
|
+
return ()
|
|
31
|
+
try:
|
|
32
|
+
return tuple(shlex.split(text))
|
|
33
|
+
except ValueError:
|
|
34
|
+
return tuple(text.split())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def file_usage() -> str:
|
|
38
|
+
return "usage: `/file put <path>` or `/file get <path>`"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_file_command(args_text: str) -> tuple[str | None, str, str | None]:
|
|
42
|
+
tokens = split_command_args(args_text)
|
|
43
|
+
if not tokens:
|
|
44
|
+
return None, "", file_usage()
|
|
45
|
+
command = tokens[0].lower()
|
|
46
|
+
rest = " ".join(tokens[1:]).strip()
|
|
47
|
+
if command not in {"put", "get"}:
|
|
48
|
+
return None, rest, file_usage()
|
|
49
|
+
return command, rest, None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_file_prompt(
|
|
53
|
+
prompt: str, *, allow_empty: bool
|
|
54
|
+
) -> tuple[str | None, bool, str | None]:
|
|
55
|
+
tokens = split_command_args(prompt)
|
|
56
|
+
force = False
|
|
57
|
+
parts: list[str] = []
|
|
58
|
+
for token in tokens:
|
|
59
|
+
if token == "--force":
|
|
60
|
+
force = True
|
|
61
|
+
continue
|
|
62
|
+
if token.startswith("--"):
|
|
63
|
+
return None, force, f"unknown flag: {token}"
|
|
64
|
+
parts.append(token)
|
|
65
|
+
path = " ".join(parts).strip()
|
|
66
|
+
if not path and not allow_empty:
|
|
67
|
+
return None, force, "missing path"
|
|
68
|
+
return (path or None), force, None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def normalize_relative_path(value: str) -> Path | None:
|
|
72
|
+
cleaned = value.strip()
|
|
73
|
+
if not cleaned:
|
|
74
|
+
return None
|
|
75
|
+
if cleaned.startswith("~"):
|
|
76
|
+
return None
|
|
77
|
+
path = Path(cleaned)
|
|
78
|
+
if path.is_absolute():
|
|
79
|
+
return None
|
|
80
|
+
parts = [part for part in path.parts if part not in {"", "."}]
|
|
81
|
+
if not parts:
|
|
82
|
+
return None
|
|
83
|
+
if ".." in parts:
|
|
84
|
+
return None
|
|
85
|
+
if ".git" in parts:
|
|
86
|
+
return None
|
|
87
|
+
return Path(*parts)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_path_within_root(root: Path, rel_path: Path) -> Path | None:
|
|
91
|
+
root_resolved = root.resolve(strict=False)
|
|
92
|
+
target = (root / rel_path).resolve(strict=False)
|
|
93
|
+
if not target.is_relative_to(root_resolved):
|
|
94
|
+
return None
|
|
95
|
+
return target
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def deny_reason(rel_path: Path, deny_globs: Sequence[str]) -> str | None:
|
|
99
|
+
if ".git" in rel_path.parts:
|
|
100
|
+
return ".git/**"
|
|
101
|
+
posix = PurePosixPath(rel_path.as_posix())
|
|
102
|
+
for pattern in deny_globs:
|
|
103
|
+
if posix.match(pattern):
|
|
104
|
+
return pattern
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def format_bytes(value: int) -> str:
|
|
109
|
+
size = max(0.0, float(value))
|
|
110
|
+
units = ("b", "kb", "mb", "gb", "tb")
|
|
111
|
+
for unit in units:
|
|
112
|
+
if size < 1024 or unit == units[-1]:
|
|
113
|
+
if unit == "b":
|
|
114
|
+
return f"{int(size)} b"
|
|
115
|
+
if size < 10:
|
|
116
|
+
return f"{size:.1f} {unit}"
|
|
117
|
+
return f"{size:.0f} {unit}"
|
|
118
|
+
size /= 1024
|
|
119
|
+
return f"{int(size)} B"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def default_upload_name(filename: str | None, file_path: str | None) -> str:
|
|
123
|
+
name = Path(filename or "").name
|
|
124
|
+
if not name and file_path:
|
|
125
|
+
name = Path(file_path).name
|
|
126
|
+
if not name:
|
|
127
|
+
name = "upload.bin"
|
|
128
|
+
return name
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def default_upload_path(
|
|
132
|
+
uploads_dir: str, filename: str | None, file_path: str | None
|
|
133
|
+
) -> Path:
|
|
134
|
+
return Path(uploads_dir) / default_upload_name(filename, file_path)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def write_bytes_atomic(path: Path, payload: bytes) -> None:
|
|
138
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
with tempfile.NamedTemporaryFile(
|
|
140
|
+
mode="wb", delete=False, dir=path.parent, prefix=".yee88-upload-"
|
|
141
|
+
) as handle:
|
|
142
|
+
handle.write(payload)
|
|
143
|
+
temp_name = handle.name
|
|
144
|
+
Path(temp_name).replace(path)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ZipTooLargeError(Exception):
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def zip_directory(
|
|
152
|
+
root: Path,
|
|
153
|
+
rel_path: Path,
|
|
154
|
+
deny_globs: Sequence[str],
|
|
155
|
+
*,
|
|
156
|
+
max_bytes: int | None = None,
|
|
157
|
+
) -> bytes:
|
|
158
|
+
target = root / rel_path
|
|
159
|
+
buffer = io.BytesIO()
|
|
160
|
+
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
161
|
+
for dirpath, _, filenames in os.walk(target, followlinks=False):
|
|
162
|
+
dir_path = Path(dirpath)
|
|
163
|
+
for filename in filenames:
|
|
164
|
+
item = dir_path / filename
|
|
165
|
+
if item.is_symlink():
|
|
166
|
+
continue
|
|
167
|
+
if not item.is_file():
|
|
168
|
+
continue
|
|
169
|
+
rel_item = rel_path / item.relative_to(target)
|
|
170
|
+
if deny_reason(rel_item, deny_globs) is not None:
|
|
171
|
+
continue
|
|
172
|
+
archive.write(item, arcname=rel_item.as_posix())
|
|
173
|
+
if max_bytes is not None and buffer.tell() > max_bytes:
|
|
174
|
+
raise ZipTooLargeError()
|
|
175
|
+
payload = buffer.getvalue()
|
|
176
|
+
if max_bytes is not None and len(payload) > max_bytes:
|
|
177
|
+
raise ZipTooLargeError()
|
|
178
|
+
return payload
|