linkgravity 1.5.14 → 1.6.1
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.
- package/bin/cli.js +22 -3
- package/bin/setup.js +17 -6
- package/package.json +1 -1
- package/src/api/ui_routes.py +7 -0
- package/src/approval/diff_preview.py +143 -0
- package/src/approval/protected_paths.py +42 -0
- package/src/approval/tool_formatter.py +9 -4
- package/src/cogs/voice_cog.py +4 -3
- package/src/config.py +4 -2
- package/src/core/logger.py +14 -0
- package/src/main_discord.py +0 -2
- package/src/main_telegram.py +17 -1
- package/src/messengers/discord_adapter.py +9 -1
- package/src/messengers/slack_adapter.py +21 -1
- package/src/messengers/telegram_adapter.py +4 -1
- package/src/services/audio_service.py +15 -4
- package/voice-service/stt.js +9 -2
package/bin/cli.js
CHANGED
|
@@ -49,6 +49,9 @@ function runPm2(args, silent = true) {
|
|
|
49
49
|
...process.env,
|
|
50
50
|
// pm2 gives Python a pipe not a TTY, so it block-buffers stdout and can sit on log lines indefinitely - force line buffering.
|
|
51
51
|
PYTHONUNBUFFERED: '1',
|
|
52
|
+
// pm2 merges --update-env rather than replacing, so a LOG_LEVEL from an earlier run
|
|
53
|
+
// survives unless a value is passed every time.
|
|
54
|
+
LOG_LEVEL: process.env.LOG_LEVEL || 'INFO',
|
|
52
55
|
// Version managers (fnm, nvm) put node on PATH from a shell hook the daemon never runs,
|
|
53
56
|
// so the bot's own `node` lookup for voice-service would fail without this.
|
|
54
57
|
PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}`,
|
|
@@ -106,7 +109,7 @@ function runSudoStepThen(sudoCommand, successMessage) {
|
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
// Matches a leading timestamp from either loguru or aiohttp's access-log format
|
|
112
|
+
// Matches a leading timestamp from either loguru or aiohttp's access-log format.
|
|
110
113
|
const TIMESTAMP_PREFIX = /^\[?\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}\]?\s*/;
|
|
111
114
|
// loguru's colorize=True puts an ANSI code before the timestamp digits, breaking the '^' anchor above.
|
|
112
115
|
// eslint-disable-next-line no-control-regex
|
|
@@ -390,7 +393,15 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
390
393
|
}
|
|
391
394
|
|
|
392
395
|
info('Starting LinkGravity daemon...');
|
|
393
|
-
runPm2([
|
|
396
|
+
runPm2([
|
|
397
|
+
'start',
|
|
398
|
+
LGY_SCRIPT_PATH,
|
|
399
|
+
'--interpreter',
|
|
400
|
+
pythonExe,
|
|
401
|
+
'--name',
|
|
402
|
+
LGY_PM2_NAME,
|
|
403
|
+
'--update-env',
|
|
404
|
+
]);
|
|
394
405
|
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
395
406
|
} else if (cmd === 'stop') {
|
|
396
407
|
info('Stopping LinkGravity daemon...');
|
|
@@ -616,7 +627,15 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
616
627
|
|
|
617
628
|
if (!procBeforeUpdate) {
|
|
618
629
|
info("Daemon wasn't running - starting it fresh...");
|
|
619
|
-
runPm2([
|
|
630
|
+
runPm2([
|
|
631
|
+
'start',
|
|
632
|
+
LGY_SCRIPT_PATH,
|
|
633
|
+
'--interpreter',
|
|
634
|
+
pythonExe,
|
|
635
|
+
'--name',
|
|
636
|
+
LGY_PM2_NAME,
|
|
637
|
+
'--update-env',
|
|
638
|
+
]);
|
|
620
639
|
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
621
640
|
} else if (wasOnline) {
|
|
622
641
|
info('Restarting daemon to apply the update...');
|
package/bin/setup.js
CHANGED
|
@@ -112,13 +112,16 @@ async function collectSessionScopes(existingScopes) {
|
|
|
112
112
|
return scopes;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
async function collectUserIds(existingIds, platformLabel) {
|
|
115
|
+
async function collectUserIds(existingIds, platformLabel, required = false) {
|
|
116
116
|
const ids = [];
|
|
117
117
|
const hasExisting = existingIds && existingIds.length > 0;
|
|
118
118
|
|
|
119
119
|
p.note(
|
|
120
|
-
|
|
121
|
-
'
|
|
120
|
+
required
|
|
121
|
+
? 'ONLY these users can use the bot. At least one is required - anyone who knows the ' +
|
|
122
|
+
'bot username can message it, and there is no channel scope to fall back on.'
|
|
123
|
+
: 'ONLY these users can use the bot (leave completely empty to allow EVERYONE). ' +
|
|
124
|
+
'Not related to DMs - this only gates the channel/threads configured above.',
|
|
122
125
|
`Allowed ${platformLabel} Users`,
|
|
123
126
|
);
|
|
124
127
|
|
|
@@ -143,7 +146,9 @@ async function collectUserIds(existingIds, platformLabel) {
|
|
|
143
146
|
while (true) {
|
|
144
147
|
const userId = await p.text({
|
|
145
148
|
message: isFirst
|
|
146
|
-
?
|
|
149
|
+
? required
|
|
150
|
+
? `${platformLabel} User ID to allow (required - message @userinfobot to find yours):`
|
|
151
|
+
: `${platformLabel} User ID to allow (leave empty to allow EVERYONE):`
|
|
147
152
|
: `Another ${platformLabel} user ID to allow (leave empty if done):`,
|
|
148
153
|
});
|
|
149
154
|
if (p.isCancel(userId)) {
|
|
@@ -151,7 +156,13 @@ async function collectUserIds(existingIds, platformLabel) {
|
|
|
151
156
|
process.exit(0);
|
|
152
157
|
}
|
|
153
158
|
|
|
154
|
-
if (!userId)
|
|
159
|
+
if (!userId) {
|
|
160
|
+
if (required && isFirst) {
|
|
161
|
+
p.note(`At least one allowed user is required for ${platformLabel}.`, 'Required');
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
155
166
|
isFirst = false;
|
|
156
167
|
|
|
157
168
|
ids.push(userId.trim());
|
|
@@ -334,7 +345,7 @@ async function configureTelegram(existingSettings) {
|
|
|
334
345
|
const existingTelegramUserIds = existingSettings.telegram_allowed_user_ids
|
|
335
346
|
? splitIds(existingSettings.telegram_allowed_user_ids)
|
|
336
347
|
: [];
|
|
337
|
-
const telegramUserIds = await collectUserIds(existingTelegramUserIds, 'Telegram');
|
|
348
|
+
const telegramUserIds = await collectUserIds(existingTelegramUserIds, 'Telegram', true);
|
|
338
349
|
|
|
339
350
|
const updates = {};
|
|
340
351
|
if (telegramToken) updates.telegram_token = telegramToken;
|
package/package.json
CHANGED
package/src/api/ui_routes.py
CHANGED
|
@@ -7,6 +7,7 @@ import uuid
|
|
|
7
7
|
from aiohttp import web
|
|
8
8
|
|
|
9
9
|
from api.server import is_tool_allowed
|
|
10
|
+
from approval.protected_paths import protected_reason
|
|
10
11
|
from config import APPROVAL_TIMEOUT_SEC, MAX_EMBED_LEN, logger, session_manager
|
|
11
12
|
from messengers.base import ScopeOption
|
|
12
13
|
from messengers.registry import get_adapter_for_platform, get_adapter_for_thread
|
|
@@ -87,6 +88,12 @@ async def handle_approve_request(request):
|
|
|
87
88
|
# DEBUG-only (see logger.py's LOG_LEVEL). Silent by default.
|
|
88
89
|
logger.debug(f"[APPROVE HOOK] tool_name={tool_name!r} conv_id={conv_id!r} tool_input={tool_input!r}")
|
|
89
90
|
|
|
91
|
+
# Ahead of the auto-allow lookup: this must not be overridable by a persistent grant.
|
|
92
|
+
blocked = protected_reason(tool_input)
|
|
93
|
+
if blocked:
|
|
94
|
+
logger.warning(f"Blocked {tool_name} touching protected config: {tool_input!r}")
|
|
95
|
+
return web.json_response({"decision": "deny", "reason": blocked})
|
|
96
|
+
|
|
90
97
|
target_thread = None
|
|
91
98
|
target_thread_id = None
|
|
92
99
|
for thread_id_str, sess in session_manager.get_all_sessions().items():
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
MAX_LINES = 120
|
|
7
|
+
TAIL_LINES = 30
|
|
8
|
+
MAX_CHARS = 5000
|
|
9
|
+
CONTEXT_LINES = 3
|
|
10
|
+
|
|
11
|
+
PATH_KEYS = ("TargetFile", "AbsolutePath")
|
|
12
|
+
|
|
13
|
+
HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)")
|
|
14
|
+
|
|
15
|
+
MISSING = object()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _existing_text(tool_input: dict):
|
|
19
|
+
path = next((tool_input[k] for k in PATH_KEYS if tool_input.get(k)), None)
|
|
20
|
+
if not path:
|
|
21
|
+
return MISSING, None
|
|
22
|
+
if not os.path.isfile(path):
|
|
23
|
+
return "", path
|
|
24
|
+
try:
|
|
25
|
+
return Path(path).read_text(encoding="utf-8"), path
|
|
26
|
+
except (OSError, UnicodeDecodeError):
|
|
27
|
+
return MISSING, path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _find_replaced_text(tool_input: dict, haystack: str, replacement: str) -> str | None:
|
|
31
|
+
# agy doesn't say which key holds the replaced text, so candidates are checked against the
|
|
32
|
+
# file instead of trusted by name.
|
|
33
|
+
best = None
|
|
34
|
+
for key, value in tool_input.items():
|
|
35
|
+
if key in PATH_KEYS or not isinstance(value, str) or not value or value == replacement:
|
|
36
|
+
continue
|
|
37
|
+
if haystack.count(value) == 1 and (best is None or len(value) > len(best)):
|
|
38
|
+
best = value
|
|
39
|
+
return best
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _entries(old: list[str], new: list[str], first_line: int, width: int) -> list[tuple[int, bool, str]]:
|
|
43
|
+
out, old_no, new_no = [], first_line, first_line
|
|
44
|
+
for line in difflib.unified_diff(old, new, lineterm="", n=CONTEXT_LINES):
|
|
45
|
+
if line.startswith(("---", "+++")):
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
header = HUNK_HEADER.match(line)
|
|
49
|
+
if header:
|
|
50
|
+
# difflib counts from the start of what it was given, which is a slice of the file
|
|
51
|
+
# for chunk edits.
|
|
52
|
+
old_no = int(header.group(1)) + first_line - 1
|
|
53
|
+
new_no = int(header.group(2)) + first_line - 1
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
sign, body = line[0], line[1:]
|
|
57
|
+
number = old_no if sign == "-" else new_no
|
|
58
|
+
# A removed line still sits at new_no, but it doesn't occupy one, so it anchors the gap
|
|
59
|
+
# marker without advancing past it.
|
|
60
|
+
out.append((new_no, sign != "-", f"{sign} {number:>{width}} | {body}"))
|
|
61
|
+
if sign in " -":
|
|
62
|
+
old_no += 1
|
|
63
|
+
if sign in " +":
|
|
64
|
+
new_no += 1
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _with_gaps(entries: list[tuple[int, bool, str]], total: int, width: int) -> list[str]:
|
|
69
|
+
def gap(count):
|
|
70
|
+
return f"{'':>{width + 5}}... ({count} unchanged {'line' if count == 1 else 'lines'})"
|
|
71
|
+
|
|
72
|
+
out, last = [], 0
|
|
73
|
+
for position, occupies, text in entries:
|
|
74
|
+
if position > last + 1:
|
|
75
|
+
out.append(gap(position - last - 1))
|
|
76
|
+
out.append(text)
|
|
77
|
+
last = position if occupies else max(last, position - 1)
|
|
78
|
+
if total > last:
|
|
79
|
+
out.append(gap(total - last))
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _clip(lines: list[str]) -> str:
|
|
84
|
+
if len(lines) > MAX_LINES:
|
|
85
|
+
head = MAX_LINES - TAIL_LINES - 1
|
|
86
|
+
lines = [*lines[:head], f"... ({len(lines) - head - TAIL_LINES} more lines)", *lines[-TAIL_LINES:]]
|
|
87
|
+
kept, size = [], 0
|
|
88
|
+
for line in lines:
|
|
89
|
+
if size + len(line) > MAX_CHARS:
|
|
90
|
+
kept.append(f"... ({len(lines) - len(kept)} more lines)")
|
|
91
|
+
break
|
|
92
|
+
kept.append(line)
|
|
93
|
+
size += len(line) + 1
|
|
94
|
+
return "\n".join(kept)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _render(name: str, entries, total: int, width: int) -> str | None:
|
|
98
|
+
if not entries:
|
|
99
|
+
return None
|
|
100
|
+
added = sum(1 for *_, text in entries if text.startswith("+"))
|
|
101
|
+
removed = sum(1 for *_, text in entries if text.startswith("-"))
|
|
102
|
+
return f"{name} · +{added} -{removed}\n{_clip(_with_gaps(entries, total, width))}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_diff(tool_input: dict) -> str | None:
|
|
106
|
+
before, path = _existing_text(tool_input)
|
|
107
|
+
if before is MISSING:
|
|
108
|
+
return None
|
|
109
|
+
name = os.path.basename(path)
|
|
110
|
+
on_disk = os.path.isfile(path)
|
|
111
|
+
|
|
112
|
+
chunks = tool_input.get("ReplacementChunks")
|
|
113
|
+
if chunks:
|
|
114
|
+
if not on_disk:
|
|
115
|
+
return None
|
|
116
|
+
existing = before.splitlines()
|
|
117
|
+
total = len(existing)
|
|
118
|
+
entries, width = [], len(str(total))
|
|
119
|
+
for chunk in chunks:
|
|
120
|
+
start, end = chunk.get("StartLine"), chunk.get("EndLine")
|
|
121
|
+
replacement = chunk.get("ReplacementContent")
|
|
122
|
+
if not isinstance(start, int) or not isinstance(end, int) or replacement is None:
|
|
123
|
+
return None
|
|
124
|
+
replaced_lines = replacement.splitlines()
|
|
125
|
+
total += len(replaced_lines) - (end - start + 1)
|
|
126
|
+
entries += _entries(existing[start - 1 : end], replaced_lines, start, width)
|
|
127
|
+
return _render(name, entries, total, width)
|
|
128
|
+
|
|
129
|
+
if "CodeContent" in tool_input:
|
|
130
|
+
content = tool_input["CodeContent"]
|
|
131
|
+
else:
|
|
132
|
+
replacement = tool_input.get("ReplacementContent")
|
|
133
|
+
if replacement is None or not on_disk:
|
|
134
|
+
return None
|
|
135
|
+
replaced = _find_replaced_text(tool_input, before, replacement)
|
|
136
|
+
if replaced is None:
|
|
137
|
+
return None
|
|
138
|
+
content = before.replace(replaced, replacement, 1)
|
|
139
|
+
|
|
140
|
+
old_lines, new_lines = before.splitlines(), content.splitlines()
|
|
141
|
+
width = len(str(max(len(old_lines), len(new_lines))))
|
|
142
|
+
label = f"{name} (new file)" if not on_disk else name
|
|
143
|
+
return _render(label, _entries(old_lines, new_lines, 1, width), len(new_lines), width)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from config import WORKSPACE_DIR
|
|
6
|
+
|
|
7
|
+
AGENT_SUBDIR = "workspace"
|
|
8
|
+
|
|
9
|
+
_DIR_MARKER = f"{WORKSPACE_DIR.parent.name}/{WORKSPACE_DIR.name}"
|
|
10
|
+
|
|
11
|
+
# Shell commands arrive as one opaque string, so they are matched textually rather than resolved.
|
|
12
|
+
CONFIG_DIR_RE = re.compile(re.escape(_DIR_MARKER) + rf"(?!/{re.escape(AGENT_SUBDIR)}\b)")
|
|
13
|
+
|
|
14
|
+
NAME_RE = re.compile(r"(?<![\w.-])(?:lgy\.json|persistent_tools\.json|approve_token)(?![\w.])")
|
|
15
|
+
|
|
16
|
+
REASON = "LinkGravity's own configuration is off limits - it holds the bot tokens."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_inside_config(value: str) -> bool:
|
|
20
|
+
try:
|
|
21
|
+
resolved = Path(os.path.expandvars(os.path.expanduser(value))).resolve()
|
|
22
|
+
except (OSError, ValueError):
|
|
23
|
+
return False
|
|
24
|
+
root = WORKSPACE_DIR.resolve()
|
|
25
|
+
return resolved.is_relative_to(root) and not resolved.is_relative_to(root / AGENT_SUBDIR)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def protected_reason(tool_input) -> str | None:
|
|
29
|
+
if isinstance(tool_input, list):
|
|
30
|
+
return next((r for r in map(protected_reason, tool_input) if r), None)
|
|
31
|
+
if not isinstance(tool_input, dict):
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
for value in tool_input.values():
|
|
35
|
+
if isinstance(value, (dict, list)):
|
|
36
|
+
nested = protected_reason(value)
|
|
37
|
+
if nested:
|
|
38
|
+
return nested
|
|
39
|
+
elif isinstance(value, str) and value:
|
|
40
|
+
if NAME_RE.search(value) or CONFIG_DIR_RE.search(value) or _is_inside_config(value):
|
|
41
|
+
return REASON
|
|
42
|
+
return None
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
3
|
|
|
4
|
+
from approval.diff_preview import build_diff
|
|
5
|
+
|
|
4
6
|
TOOL_DISPLAY_NAMES = {
|
|
5
7
|
"view_file": "Read",
|
|
6
8
|
"write_to_file": "Write",
|
|
@@ -39,14 +41,17 @@ def format_tool_display(tool_name: str, tool_input: dict) -> tuple[str, str, dic
|
|
|
39
41
|
fields_text += f"**{k}**: {v}\n"
|
|
40
42
|
|
|
41
43
|
code_text = ""
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
diff = build_diff(tool_input)
|
|
45
|
+
if diff:
|
|
46
|
+
code_text = f"\n**Changes:**\n```diff\n{diff}\n```"
|
|
47
|
+
elif "CodeContent" in tool_input:
|
|
48
|
+
code_text = f"\n**Code Content:**\n```\n{tool_input['CodeContent'][:1000]}\n```"
|
|
44
49
|
elif "ReplacementChunks" in tool_input:
|
|
45
50
|
for i, chunk in enumerate(tool_input["ReplacementChunks"]):
|
|
46
51
|
code_text += f"\n**Replacement Chunk #{i + 1} (Lines {chunk.get('StartLine')}-{chunk.get('EndLine')}):**\n"
|
|
47
|
-
code_text += f"
|
|
52
|
+
code_text += f"```\n{chunk.get('ReplacementContent')[:500]}\n```"
|
|
48
53
|
elif "ReplacementContent" in tool_input:
|
|
49
|
-
code_text += f"\n**Replacement Content:**\n
|
|
54
|
+
code_text += f"\n**Replacement Content:**\n```\n{tool_input['ReplacementContent'][:1000]}\n```"
|
|
50
55
|
|
|
51
56
|
if fields_text or code_text:
|
|
52
57
|
desc_json = f"\n{fields_text}{code_text}"
|
package/src/cogs/voice_cog.py
CHANGED
|
@@ -290,12 +290,13 @@ class VoiceCog(commands.Cog):
|
|
|
290
290
|
|
|
291
291
|
if own_word or not required:
|
|
292
292
|
if self.bot_settings.get("tts_enabled", True):
|
|
293
|
-
welcome_audio = await self.tts("Voice connected.")
|
|
293
|
+
welcome_audio = await self.tts("Voice connected.", cache=True)
|
|
294
294
|
if welcome_audio:
|
|
295
295
|
await self._play_audio(str(guild_id), welcome_audio, suppress_active_window=True)
|
|
296
296
|
elif self.bot_settings.get("tts_enabled", True):
|
|
297
297
|
prompt_audio = await self.tts(
|
|
298
|
-
"No wake word is set up yet. Please use the sound command to set one."
|
|
298
|
+
"No wake word is set up yet. Please use the sound command to set one.",
|
|
299
|
+
cache=True,
|
|
299
300
|
)
|
|
300
301
|
if prompt_audio:
|
|
301
302
|
await self._play_audio(str(guild_id), prompt_audio, suppress_active_window=True)
|
|
@@ -637,7 +638,7 @@ class VoiceCog(commands.Cog):
|
|
|
637
638
|
if not text_to_ai:
|
|
638
639
|
self.logger.debug("STT: isolated wake word handled via direct TTS")
|
|
639
640
|
if self.bot_settings.get("tts_enabled", True):
|
|
640
|
-
audio_reply = await self.tts("Yes, I am listening.")
|
|
641
|
+
audio_reply = await self.tts("Yes, I am listening.", cache=True)
|
|
641
642
|
if audio_reply:
|
|
642
643
|
await self._play_audio(str(guild_id), audio_reply)
|
|
643
644
|
return
|
package/src/config.py
CHANGED
|
@@ -165,8 +165,10 @@ session_manager = SessionManager(DATA_DIR)
|
|
|
165
165
|
|
|
166
166
|
def allowed(user_id, platform: str = "discord") -> bool:
|
|
167
167
|
if platform == "telegram":
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
# Anyone who knows a Telegram bot's username can DM it, and there is no channel scope to
|
|
169
|
+
# fall back on, so an empty list must mean nobody rather than everyone.
|
|
170
|
+
return user_id in TELEGRAM_ALLOWED_IDS
|
|
171
|
+
if platform == "slack":
|
|
170
172
|
ids = SLACK_ALLOWED_IDS
|
|
171
173
|
user_id = str(user_id) # Slack IDs are strings, not ints like Discord/Telegram
|
|
172
174
|
else:
|
package/src/core/logger.py
CHANGED
|
@@ -6,6 +6,17 @@ from sys import stdout
|
|
|
6
6
|
from loguru import logger
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
class _InterceptHandler(logging.Handler):
|
|
10
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
11
|
+
try:
|
|
12
|
+
level = logger.level(record.levelname).name
|
|
13
|
+
except ValueError:
|
|
14
|
+
level = record.levelno
|
|
15
|
+
# Without patching, {name} resolves to this file's frame instead of the library that logged.
|
|
16
|
+
patched = logger.patch(lambda r, name=record.name: r.update(name=name))
|
|
17
|
+
patched.opt(exception=record.exc_info).log(level, record.getMessage())
|
|
18
|
+
|
|
19
|
+
|
|
9
20
|
def init_logger(workspace_dir: Path):
|
|
10
21
|
logging.getLogger("discord").setLevel(logging.WARNING)
|
|
11
22
|
# httpx is what python-telegram-bot uses under the hood for every getUpdates
|
|
@@ -22,6 +33,9 @@ def init_logger(workspace_dir: Path):
|
|
|
22
33
|
# Defaults to INFO - set LOG_LEVEL=DEBUG then `lgy restart` for
|
|
23
34
|
# verbose detail (e.g. agy_runner.py's raw agy stdout capture).
|
|
24
35
|
level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
|
36
|
+
# force=True drops handlers third-party libraries install for themselves, which otherwise
|
|
37
|
+
# print in their own format alongside loguru's.
|
|
38
|
+
logging.basicConfig(handlers=[_InterceptHandler()], level=getattr(logging, level, logging.INFO), force=True)
|
|
25
39
|
logger.add(stdout, level=level, format=log_format, colorize=True)
|
|
26
40
|
logger.add(
|
|
27
41
|
LOG_DIR / "bot.log",
|
package/src/main_discord.py
CHANGED
|
@@ -281,8 +281,6 @@ async def run_discord(stop_event: asyncio.Event) -> None:
|
|
|
281
281
|
logger.critical("Missing DISCORD_TOKEN - run `lgy setup`")
|
|
282
282
|
return
|
|
283
283
|
|
|
284
|
-
discord.utils.setup_logging()
|
|
285
|
-
|
|
286
284
|
async with bot:
|
|
287
285
|
from cogs.voice_cog import VoiceCog
|
|
288
286
|
from config import bot_settings, save_bot_settings
|
package/src/main_telegram.py
CHANGED
|
@@ -6,7 +6,15 @@ from pathlib import Path
|
|
|
6
6
|
from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, Update
|
|
7
7
|
from telegram.ext import Application, ApplicationBuilder, CallbackQueryHandler, CommandHandler, MessageHandler, filters
|
|
8
8
|
|
|
9
|
-
from config import
|
|
9
|
+
from config import (
|
|
10
|
+
TELEGRAM_ALLOWED_IDS,
|
|
11
|
+
TELEGRAM_TOKEN,
|
|
12
|
+
allowed,
|
|
13
|
+
bot_settings,
|
|
14
|
+
logger,
|
|
15
|
+
save_bot_settings,
|
|
16
|
+
session_manager,
|
|
17
|
+
)
|
|
10
18
|
from core.atomic_io import atomic_write_json, safe_load_json
|
|
11
19
|
from handlers.message_router import handle_message
|
|
12
20
|
from messengers.registry import register_adapter
|
|
@@ -252,6 +260,14 @@ async def run_telegram(stop_event: asyncio.Event) -> None:
|
|
|
252
260
|
logger.critical("Missing TELEGRAM_TOKEN - set telegram_token in lgy.json first.")
|
|
253
261
|
return
|
|
254
262
|
|
|
263
|
+
if not TELEGRAM_ALLOWED_IDS:
|
|
264
|
+
logger.critical(
|
|
265
|
+
"No telegram_allowed_user_ids configured - refusing to start. A Telegram bot is "
|
|
266
|
+
"reachable by anyone who knows its username, so run 'lgy setup' and register your "
|
|
267
|
+
"user ID (message @userinfobot to find it)."
|
|
268
|
+
)
|
|
269
|
+
return
|
|
270
|
+
|
|
255
271
|
app = build_application()
|
|
256
272
|
logger.info("✅ Telegram bot starting (polling mode)...")
|
|
257
273
|
await app.initialize()
|
|
@@ -7,7 +7,7 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
import discord
|
|
9
9
|
|
|
10
|
-
from config import logger
|
|
10
|
+
from config import allowed, logger
|
|
11
11
|
from messengers.base import (
|
|
12
12
|
IncomingAttachment,
|
|
13
13
|
IncomingMessage,
|
|
@@ -21,6 +21,14 @@ from messengers.base import (
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class _ErrorLoggingView(discord.ui.View):
|
|
24
|
+
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
|
25
|
+
# discord.py lets anyone who can see the message press the button, so the allow-list has
|
|
26
|
+
# to be applied here as well as on the inbound message path.
|
|
27
|
+
if allowed(interaction.user.id):
|
|
28
|
+
return True
|
|
29
|
+
await interaction.response.send_message("⛔ You are not allowed to use this bot.", ephemeral=True)
|
|
30
|
+
return False
|
|
31
|
+
|
|
24
32
|
async def on_error(self, interaction: discord.Interaction, error: Exception, item) -> None:
|
|
25
33
|
logger.exception(f"Discord button interaction error: {error}")
|
|
26
34
|
error_msg = "⚠️ **An error occurred while processing this button.** Please try again later or check the logs."
|
|
@@ -12,7 +12,7 @@ from slack_bolt.app.async_app import AsyncApp
|
|
|
12
12
|
from slack_sdk.errors import SlackApiError
|
|
13
13
|
from slack_sdk.web.async_client import AsyncWebClient
|
|
14
14
|
|
|
15
|
-
from config import logger, session_manager
|
|
15
|
+
from config import allowed, logger, session_manager
|
|
16
16
|
from messengers.base import (
|
|
17
17
|
IncomingAttachment,
|
|
18
18
|
IncomingMessage,
|
|
@@ -222,13 +222,33 @@ class SlackAdapter(MessengerAdapter):
|
|
|
222
222
|
actions = body.get("actions") or []
|
|
223
223
|
if not actions:
|
|
224
224
|
return
|
|
225
|
+
if not await self._reject_unauthorized(body):
|
|
226
|
+
return
|
|
225
227
|
action_id = actions[0].get("action_id")
|
|
226
228
|
handler = self._callbacks.pop(action_id, None)
|
|
227
229
|
if handler is None:
|
|
228
230
|
return # expired/unknown action - nothing to do, Bolt already acked
|
|
229
231
|
await handler(body, self.client)
|
|
230
232
|
|
|
233
|
+
async def _reject_unauthorized(self, body: dict) -> bool:
|
|
234
|
+
user_id = (body.get("user") or {}).get("id")
|
|
235
|
+
if allowed(user_id, "slack"):
|
|
236
|
+
return True
|
|
237
|
+
logger.warning(f"Rejected Slack interaction from unauthorized user {user_id}")
|
|
238
|
+
# Modal submissions carry no channel, so there is nowhere to post the notice.
|
|
239
|
+
channel = (body.get("channel") or {}).get("id")
|
|
240
|
+
if channel and user_id:
|
|
241
|
+
try:
|
|
242
|
+
await self.client.chat_postEphemeral(
|
|
243
|
+
channel=channel, user=user_id, text="⛔ You are not allowed to use this bot."
|
|
244
|
+
)
|
|
245
|
+
except SlackApiError as e:
|
|
246
|
+
logger.warning(f"Failed to notify unauthorized Slack user {user_id}: {e}")
|
|
247
|
+
return False
|
|
248
|
+
|
|
231
249
|
async def handle_view_submission(self, body: dict) -> None:
|
|
250
|
+
if not await self._reject_unauthorized(body):
|
|
251
|
+
return
|
|
232
252
|
callback_id = (body.get("view") or {}).get("callback_id")
|
|
233
253
|
handler = self._view_callbacks.pop(callback_id, None)
|
|
234
254
|
if handler is None:
|
|
@@ -14,7 +14,7 @@ from telegram.constants import ChatAction
|
|
|
14
14
|
from telegram.error import TelegramError
|
|
15
15
|
from telegram.ext import ContextTypes
|
|
16
16
|
|
|
17
|
-
from config import logger
|
|
17
|
+
from config import allowed, logger
|
|
18
18
|
from messengers.base import (
|
|
19
19
|
IncomingAttachment,
|
|
20
20
|
IncomingMessage,
|
|
@@ -162,6 +162,9 @@ class TelegramAdapter(MessengerAdapter):
|
|
|
162
162
|
query = update.callback_query
|
|
163
163
|
if query is None or query.data is None:
|
|
164
164
|
return
|
|
165
|
+
if query.from_user is None or not allowed(query.from_user.id, "telegram"):
|
|
166
|
+
await query.answer("You are not allowed to use this bot.", show_alert=True)
|
|
167
|
+
return
|
|
165
168
|
handler = self._callbacks.pop(query.data, None)
|
|
166
169
|
if handler is None:
|
|
167
170
|
await query.answer("This action has expired.", show_alert=True)
|
|
@@ -7,7 +7,6 @@ from pathlib import Path
|
|
|
7
7
|
from config import TTS_VOICE, bot_settings, logger
|
|
8
8
|
|
|
9
9
|
# Verified against edge-tts's live voice list; a name that isn't in it fails at synthesis time.
|
|
10
|
-
# Ordered - the first voice of a language is that language's default.
|
|
11
10
|
TTS_VOICES = [
|
|
12
11
|
"en-US-AriaNeural",
|
|
13
12
|
"en-US-GuyNeural",
|
|
@@ -83,7 +82,6 @@ def default_voice_for(tag: str) -> str:
|
|
|
83
82
|
|
|
84
83
|
|
|
85
84
|
def resolve_language() -> str:
|
|
86
|
-
# Mirrored by resolveLanguage() in voice-service/stt.js, which is what reaches Google.
|
|
87
85
|
configured = bot_settings.get("language")
|
|
88
86
|
if configured:
|
|
89
87
|
return configured
|
|
@@ -101,7 +99,12 @@ def voice_for(text: str) -> str:
|
|
|
101
99
|
return default_voice_for(script)
|
|
102
100
|
|
|
103
101
|
|
|
104
|
-
|
|
102
|
+
# Synthesising a prompt costs a network round trip that lands directly in the wake-word
|
|
103
|
+
# response time, so the fixed ones are kept rather than rebuilt.
|
|
104
|
+
_fixed_phrase_cache: dict[tuple[str, str, str], bytes] = {}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def tts(text: str, voice: str = None, cache: bool = False) -> bytes | None:
|
|
105
108
|
try:
|
|
106
109
|
import edge_tts
|
|
107
110
|
|
|
@@ -115,12 +118,20 @@ async def tts(text: str, voice: str = None) -> bytes | None:
|
|
|
115
118
|
pct = round((speed - 1.0) * 100)
|
|
116
119
|
rate = f"{'+' if pct >= 0 else ''}{pct}%"
|
|
117
120
|
|
|
118
|
-
|
|
121
|
+
active_voice = voice or voice_for(clean)
|
|
122
|
+
|
|
123
|
+
key = (clean, active_voice, rate)
|
|
124
|
+
if cache and key in _fixed_phrase_cache:
|
|
125
|
+
return _fixed_phrase_cache[key]
|
|
126
|
+
|
|
127
|
+
communicate = edge_tts.Communicate(clean, active_voice, rate=rate)
|
|
119
128
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
|
120
129
|
tmp = f.name
|
|
121
130
|
await communicate.save(tmp)
|
|
122
131
|
data = Path(tmp).read_bytes()
|
|
123
132
|
os.unlink(tmp)
|
|
133
|
+
if cache:
|
|
134
|
+
_fixed_phrase_cache[key] = data
|
|
124
135
|
return data
|
|
125
136
|
except Exception as e:
|
|
126
137
|
logger.exception(f"TTS error: {e}")
|
package/voice-service/stt.js
CHANGED
|
@@ -5,8 +5,8 @@ const { aglConfig } = require('./config');
|
|
|
5
5
|
// Unofficial Google STT key - same default Python's SpeechRecognition (recognize_google) ships with.
|
|
6
6
|
const GOOGLE_STT_KEY = 'AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw';
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
8
|
+
// edge-tts voice names are <lang>-<REGION>-<Name>, so a config predating the language setting
|
|
9
|
+
// carries its language in tts_voice alone.
|
|
10
10
|
function resolveLanguage() {
|
|
11
11
|
if (aglConfig.language) return aglConfig.language;
|
|
12
12
|
const match = /^([a-z]{2}-[A-Z]{2})-/.exec(aglConfig.tts_voice || '');
|
|
@@ -41,6 +41,7 @@ function flacEncode(wavBuffer) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
async function googleSTT(wavBuffer, lang = LANGUAGE) {
|
|
44
|
+
const startedAt = Date.now();
|
|
44
45
|
let flacBuffer;
|
|
45
46
|
try {
|
|
46
47
|
flacBuffer = await flacEncode(wavBuffer);
|
|
@@ -49,6 +50,8 @@ async function googleSTT(wavBuffer, lang = LANGUAGE) {
|
|
|
49
50
|
return null;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
const encodedAt = Date.now();
|
|
54
|
+
|
|
52
55
|
let res;
|
|
53
56
|
try {
|
|
54
57
|
res = await fetch(
|
|
@@ -65,6 +68,10 @@ async function googleSTT(wavBuffer, lang = LANGUAGE) {
|
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
const raw = await res.text();
|
|
71
|
+
console.debug(
|
|
72
|
+
`[STT] ${Math.round(wavBuffer.length / 192)}ms audio: flac ${encodedAt - startedAt}ms, ` +
|
|
73
|
+
`google ${Date.now() - encodedAt}ms`,
|
|
74
|
+
);
|
|
68
75
|
console.debug('[STT] raw:', raw.trim().replace(/\n/g, ' | '));
|
|
69
76
|
// Response is newline-delimited JSON, one object per line.
|
|
70
77
|
for (const line of raw.trim().split('\n')) {
|