alive-ai 0.1.8 → 0.1.10
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/README.md +3 -2
- package/cli/index.js +195 -2
- package/cli/tui.js +6 -2
- package/core/message_handler.py +6 -1
- package/docs/index.html +2 -2
- package/input/telegram/listener.py +48 -14
- package/main.py +53 -1
- package/output/voice/gtts_tts.py +17 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/README.md
CHANGED
|
@@ -61,10 +61,11 @@ alive-ai init my-ai
|
|
|
61
61
|
| `npx . demo` | Run a keyless animated dashboard demo. |
|
|
62
62
|
| `npx . start` | Start the runtime using the configured input channel, usually Telegram. |
|
|
63
63
|
| `npx . start --skip-install` | Start again without reinstalling Python dependencies. |
|
|
64
|
+
| `npx . stop` | Stop the running Alive-AI process for this project. |
|
|
64
65
|
| `npx . update` | Refresh runtime files from the latest npm package while preserving config/data/media. |
|
|
65
66
|
| `npx . uninstall` | Remove Alive-AI runtime files, config, venv, cache, data, and media from the project. |
|
|
66
67
|
|
|
67
|
-
`start` and `chat` check npm for a newer Alive-AI version. You can update, skip once, or skip that specific version. Stop terminal chat with `/exit` or `Ctrl+C`.
|
|
68
|
+
`start` and `chat` check npm for a newer Alive-AI version. You can update, skip once, or skip that specific version. Stop terminal chat with `/exit` or `Ctrl+C`. Stop foreground Telegram/runtime mode with `Ctrl+C`; if a stale process is still alive, run `npx . stop` from the project root.
|
|
68
69
|
|
|
69
70
|
`doctor --fix` is conservative: it prints the exact install command before running anything and asks separately for each missing tool. On macOS it uses Homebrew, on Windows it uses winget, and on Linux it supports apt, dnf, and pacman where possible. Redis is optional; doctor only checks or fixes it when `REDIS_VECTOR_MEMORY_ENABLED` is true.
|
|
70
71
|
|
|
@@ -292,7 +293,7 @@ Implemented:
|
|
|
292
293
|
- [x] Local WebUI dashboard with live state streaming
|
|
293
294
|
- [x] Optional hybrid OpenMind cloud/local semantic memory
|
|
294
295
|
- [x] Optional Redis Stack vector cache with setup and doctor checks
|
|
295
|
-
- [x] npm/npx CLI scaffold, setup, doctor, demo, chat, and
|
|
296
|
+
- [x] npm/npx CLI scaffold, setup, doctor, demo, chat, start, stop, and uninstall commands
|
|
296
297
|
- [x] Update prompt and project uninstall command
|
|
297
298
|
- [x] `doctor --fix` guided system dependency installer
|
|
298
299
|
- [x] Clean public repo with private personas, media, runtime data, and multi-AI orchestration removed
|
package/cli/index.js
CHANGED
|
@@ -48,6 +48,7 @@ Usage:
|
|
|
48
48
|
alive-ai demo [--port 8080] Run the animated dashboard demo
|
|
49
49
|
alive-ai update [--yes] Update this project from the latest package
|
|
50
50
|
alive-ai start [--skip-install] Install Python deps if needed and start runtime
|
|
51
|
+
alive-ai stop Stop the running project runtime
|
|
51
52
|
alive-ai chat [--skip-install] Start split-pane terminal chat and logs
|
|
52
53
|
alive-ai chat --plain Start raw terminal chat without the TUI
|
|
53
54
|
alive-ai doctor [--fix] Check local prerequisites and optionally install missing tools
|
|
@@ -61,6 +62,7 @@ Quick start:
|
|
|
61
62
|
npx . chat
|
|
62
63
|
npx . demo
|
|
63
64
|
npx . start
|
|
65
|
+
npx . stop
|
|
64
66
|
npx . uninstall`);
|
|
65
67
|
}
|
|
66
68
|
|
|
@@ -934,6 +936,116 @@ function ensurePythonEnv(skipInstall) {
|
|
|
934
936
|
return pythonBin;
|
|
935
937
|
}
|
|
936
938
|
|
|
939
|
+
function isChildRunning(child) {
|
|
940
|
+
return child && child.exitCode === null && child.signalCode === null;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function signalExitCode(signal) {
|
|
944
|
+
if (signal === "SIGINT") return 130;
|
|
945
|
+
if (signal === "SIGTERM") return 143;
|
|
946
|
+
if (signal === "SIGHUP") return 129;
|
|
947
|
+
if (signal === "SIGKILL") return 137;
|
|
948
|
+
return 1;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function runtimeInfoPath() {
|
|
952
|
+
return path.join(process.cwd(), ".alive-ai", "runtime.json");
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function legacyRuntimePidPath() {
|
|
956
|
+
return path.join(process.cwd(), ".alive-ai", "runtime.pid");
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function readRuntimeInfo() {
|
|
960
|
+
try {
|
|
961
|
+
const info = readJson(runtimeInfoPath());
|
|
962
|
+
const pid = Number(info.pid);
|
|
963
|
+
return Number.isInteger(pid) && pid > 0 ? { ...info, pid } : null;
|
|
964
|
+
} catch {}
|
|
965
|
+
|
|
966
|
+
try {
|
|
967
|
+
const pid = Number(fs.readFileSync(legacyRuntimePidPath(), "utf8").trim());
|
|
968
|
+
return Number.isInteger(pid) && pid > 0 ? { pid } : null;
|
|
969
|
+
} catch {
|
|
970
|
+
return null;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function writeRuntimeInfo(pid, details = {}) {
|
|
975
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
976
|
+
writeJson(runtimeInfoPath(), {
|
|
977
|
+
pid,
|
|
978
|
+
cwd: process.cwd(),
|
|
979
|
+
startedAt: new Date().toISOString(),
|
|
980
|
+
...details,
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function clearRuntimeInfo(pid) {
|
|
985
|
+
const info = readRuntimeInfo();
|
|
986
|
+
if (info && pid && info.pid !== pid) return;
|
|
987
|
+
for (const filePath of [runtimeInfoPath(), legacyRuntimePidPath()]) {
|
|
988
|
+
try {
|
|
989
|
+
fs.rmSync(filePath, { force: true });
|
|
990
|
+
} catch {}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function processIsAlive(pid) {
|
|
995
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
996
|
+
try {
|
|
997
|
+
process.kill(pid, 0);
|
|
998
|
+
return true;
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
return error && error.code === "EPERM";
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function waitForProcessExit(pid, timeoutMs) {
|
|
1005
|
+
const deadline = Date.now() + timeoutMs;
|
|
1006
|
+
return new Promise((resolve) => {
|
|
1007
|
+
const tick = () => {
|
|
1008
|
+
if (!processIsAlive(pid)) return resolve(true);
|
|
1009
|
+
if (Date.now() >= deadline) return resolve(false);
|
|
1010
|
+
setTimeout(tick, 150);
|
|
1011
|
+
};
|
|
1012
|
+
tick();
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
async function stopProjectRuntime() {
|
|
1017
|
+
const info = readRuntimeInfo();
|
|
1018
|
+
if (!info || !info.pid) {
|
|
1019
|
+
console.log("No Alive-AI runtime pid found for this project.");
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (!processIsAlive(info.pid)) {
|
|
1024
|
+
clearRuntimeInfo(info.pid);
|
|
1025
|
+
console.log("Removed stale Alive-AI runtime pid file.");
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
console.log(`Stopping Alive-AI runtime pid ${info.pid}...`);
|
|
1030
|
+
try {
|
|
1031
|
+
process.kill(info.pid, "SIGTERM");
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
console.error(`Could not stop pid ${info.pid}: ${error.message}`);
|
|
1034
|
+
process.exit(1);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (!(await waitForProcessExit(info.pid, 5000))) {
|
|
1038
|
+
console.error("Alive-AI did not stop cleanly; forcing shutdown.");
|
|
1039
|
+
try {
|
|
1040
|
+
process.kill(info.pid, "SIGKILL");
|
|
1041
|
+
} catch {}
|
|
1042
|
+
await waitForProcessExit(info.pid, 2000);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
clearRuntimeInfo(info.pid);
|
|
1046
|
+
console.log("Alive-AI stopped.");
|
|
1047
|
+
}
|
|
1048
|
+
|
|
937
1049
|
async function startRuntime(args, options = {}) {
|
|
938
1050
|
if (!fs.existsSync(path.join(process.cwd(), "config", "settings.json"))) {
|
|
939
1051
|
console.log("Missing config/settings.json. Starting onboarding first.");
|
|
@@ -969,25 +1081,105 @@ async function startRuntime(args, options = {}) {
|
|
|
969
1081
|
};
|
|
970
1082
|
if (options.tui) env.ALIVE_AI_TUI = "1";
|
|
971
1083
|
|
|
1084
|
+
const existingRuntime = readRuntimeInfo();
|
|
1085
|
+
if (existingRuntime && processIsAlive(existingRuntime.pid)) {
|
|
1086
|
+
console.error(`Alive-AI already appears to be running for this project (pid ${existingRuntime.pid}).`);
|
|
1087
|
+
console.error("Run `npx . stop` before starting a new session.");
|
|
1088
|
+
process.exit(1);
|
|
1089
|
+
}
|
|
1090
|
+
if (existingRuntime) clearRuntimeInfo(existingRuntime.pid);
|
|
1091
|
+
|
|
972
1092
|
const child = spawn(pythonBin, ["main.py", ...extraArgs], {
|
|
973
1093
|
stdio: options.tui ? ["pipe", "pipe", "pipe"] : "inherit",
|
|
974
1094
|
cwd: process.cwd(),
|
|
975
1095
|
env,
|
|
976
1096
|
});
|
|
1097
|
+
writeRuntimeInfo(child.pid, { inputChannel: effectiveInputChannel });
|
|
977
1098
|
|
|
978
1099
|
if (options.tui) {
|
|
979
1100
|
const code = await runRuntimeTui(child, {
|
|
980
1101
|
dashboard: `http://127.0.0.1:${readProjectSettings().WEBUI_PORT || DEFAULT_PORT}`,
|
|
981
1102
|
});
|
|
1103
|
+
clearRuntimeInfo(child.pid);
|
|
982
1104
|
process.exitCode = code;
|
|
983
1105
|
return;
|
|
984
1106
|
}
|
|
985
1107
|
|
|
986
1108
|
await new Promise((resolve) => {
|
|
987
|
-
|
|
988
|
-
|
|
1109
|
+
let stopRequested = false;
|
|
1110
|
+
let stopSignal = null;
|
|
1111
|
+
let finished = false;
|
|
1112
|
+
const timers = [];
|
|
1113
|
+
|
|
1114
|
+
function cleanup() {
|
|
1115
|
+
for (const timer of timers) clearTimeout(timer);
|
|
1116
|
+
process.off("SIGINT", onSignal);
|
|
1117
|
+
process.off("SIGTERM", onSignal);
|
|
1118
|
+
process.off("SIGHUP", onSignal);
|
|
1119
|
+
clearRuntimeInfo(child.pid);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function requestStop(signal) {
|
|
1123
|
+
stopSignal = stopSignal || signal;
|
|
1124
|
+
if (stopRequested) {
|
|
1125
|
+
if (isChildRunning(child)) {
|
|
1126
|
+
console.error("\nAlive-AI is still stopping; forcing shutdown.");
|
|
1127
|
+
child.kill("SIGKILL");
|
|
1128
|
+
}
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
stopRequested = true;
|
|
1133
|
+
process.exitCode = signalExitCode(signal);
|
|
1134
|
+
console.log(`\nStopping Alive-AI (${signal})...`);
|
|
1135
|
+
|
|
1136
|
+
try {
|
|
1137
|
+
child.kill(signal === "SIGHUP" ? "SIGTERM" : signal);
|
|
1138
|
+
} catch {}
|
|
1139
|
+
|
|
1140
|
+
timers.push(setTimeout(() => {
|
|
1141
|
+
if (isChildRunning(child)) child.kill("SIGTERM");
|
|
1142
|
+
}, 1500));
|
|
1143
|
+
|
|
1144
|
+
timers.push(setTimeout(() => {
|
|
1145
|
+
if (isChildRunning(child)) {
|
|
1146
|
+
console.error("Alive-AI did not stop cleanly; forcing shutdown.");
|
|
1147
|
+
child.kill("SIGKILL");
|
|
1148
|
+
}
|
|
1149
|
+
}, 5000));
|
|
1150
|
+
|
|
1151
|
+
for (const timer of timers) timer.unref();
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function onSignal(signal) {
|
|
1155
|
+
requestStop(signal);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
child.on("error", (error) => {
|
|
1159
|
+
if (finished) return;
|
|
1160
|
+
finished = true;
|
|
1161
|
+
cleanup();
|
|
1162
|
+
console.error(`Alive-AI failed to start: ${error.message}`);
|
|
1163
|
+
process.exitCode = 1;
|
|
989
1164
|
resolve();
|
|
990
1165
|
});
|
|
1166
|
+
child.on("close", (code, signal) => {
|
|
1167
|
+
if (finished) return;
|
|
1168
|
+
finished = true;
|
|
1169
|
+
cleanup();
|
|
1170
|
+
if (stopRequested) {
|
|
1171
|
+
process.exitCode = code && code !== 0 ? code : signalExitCode(signal || stopSignal);
|
|
1172
|
+
} else if (typeof code === "number") {
|
|
1173
|
+
process.exitCode = code;
|
|
1174
|
+
} else {
|
|
1175
|
+
process.exitCode = signalExitCode(signal || stopSignal);
|
|
1176
|
+
}
|
|
1177
|
+
resolve();
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
process.on("SIGINT", onSignal);
|
|
1181
|
+
process.on("SIGTERM", onSignal);
|
|
1182
|
+
process.on("SIGHUP", onSignal);
|
|
991
1183
|
});
|
|
992
1184
|
}
|
|
993
1185
|
|
|
@@ -1082,6 +1274,7 @@ async function main() {
|
|
|
1082
1274
|
if (command === "update") return updateProject(args);
|
|
1083
1275
|
if (command === "demo") return startDemo(args);
|
|
1084
1276
|
if (command === "start") return startRuntime(args);
|
|
1277
|
+
if (command === "stop") return stopProjectRuntime(args);
|
|
1085
1278
|
if (command === "chat") return startTerminalChat(args);
|
|
1086
1279
|
if (command === "doctor") return doctor(args);
|
|
1087
1280
|
if (command === "uninstall") return uninstallProject(args);
|
package/cli/tui.js
CHANGED
|
@@ -65,6 +65,10 @@ function runRuntimeTui(child, options = {}) {
|
|
|
65
65
|
let resolved = false;
|
|
66
66
|
const isTty = process.stdin.isTTY && process.stdout.isTTY;
|
|
67
67
|
|
|
68
|
+
function childRunning() {
|
|
69
|
+
return child.exitCode === null && child.signalCode === null;
|
|
70
|
+
}
|
|
71
|
+
|
|
68
72
|
function addChat(role, text) {
|
|
69
73
|
state.chat.push({ role, text: String(text || "") });
|
|
70
74
|
if (state.chat.length > 200) state.chat = state.chat.slice(-200);
|
|
@@ -173,10 +177,10 @@ function runRuntimeTui(child, options = {}) {
|
|
|
173
177
|
if (child.stdin.writable) child.stdin.end();
|
|
174
178
|
} catch {}
|
|
175
179
|
setTimeout(() => {
|
|
176
|
-
if (
|
|
180
|
+
if (childRunning()) child.kill("SIGTERM");
|
|
177
181
|
}, 1200).unref();
|
|
178
182
|
setTimeout(() => {
|
|
179
|
-
if (
|
|
183
|
+
if (childRunning()) child.kill("SIGKILL");
|
|
180
184
|
}, 3500).unref();
|
|
181
185
|
}
|
|
182
186
|
|
package/core/message_handler.py
CHANGED
|
@@ -1102,7 +1102,12 @@ async def _send_response(self, response, emotion, chat_id, text, user_id="defaul
|
|
|
1102
1102
|
await self.nervous.emit("chat_action_voice", {})
|
|
1103
1103
|
vp = await self._voice.generate(response, mood=mood)
|
|
1104
1104
|
if vp:
|
|
1105
|
-
await self.nervous.emit("send_voice_file", {
|
|
1105
|
+
await self.nervous.emit("send_voice_file", {
|
|
1106
|
+
"file_path": vp,
|
|
1107
|
+
"chat_id": chat_id,
|
|
1108
|
+
"fallback_text": response,
|
|
1109
|
+
"mood": mood,
|
|
1110
|
+
})
|
|
1106
1111
|
return
|
|
1107
1112
|
await self.nervous.emit("send_text", {"text": response, "mood": mood, "chat_id": chat_id})
|
|
1108
1113
|
|
package/docs/index.html
CHANGED
|
@@ -291,9 +291,9 @@ npx . chat</code></pre>
|
|
|
291
291
|
<p>`npx . setup` asks for the minimum required configuration and lets optional systems be skipped. Use `local` for Ollama, `skip` for optional keys, OpenMind cloud or local for shared long-term semantic memory, and leave Redis off unless you specifically want a local Redis Stack vector cache.</p>
|
|
292
292
|
<div class="steps">
|
|
293
293
|
<div class="step"><strong>Start local terminal chat</strong><span>`npx . chat` starts the same runtime with chat on the left, logs on the right, and the WebUI at `http://127.0.0.1:8080`.</span></div>
|
|
294
|
-
<div class="step"><strong>Start Telegram/runtime mode</strong><span>`npx . start` starts the configured input channel and validates Telegram before polling. Stop foreground runs with `Ctrl+C
|
|
294
|
+
<div class="step"><strong>Start Telegram/runtime mode</strong><span>`npx . start` starts the configured input channel and validates Telegram before polling. Stop foreground runs with `Ctrl+C`; use `npx . stop` to clear a stale project runtime.</span></div>
|
|
295
295
|
<div class="step"><strong>Install missing tools</strong><span>`npx . doctor --fix` asks `y/N` for each missing tool. Redis is checked only when the Redis vector cache is enabled.</span></div>
|
|
296
|
-
<div class="step"><strong>Keep it updated</strong><span>`start` and `chat` check npm for newer versions. Use `npx . update` manually or `npx . uninstall` to remove local runtime files.</span></div>
|
|
296
|
+
<div class="step"><strong>Keep it updated</strong><span>`start` and `chat` check npm for newer versions. Use `npx . update` manually, `npx . stop` for a stuck session, or `npx . uninstall` to remove local runtime files.</span></div>
|
|
297
297
|
<div class="step"><strong>Use OpenMind</strong><span>Choose `openmind-cloud` for `https://theopenmind.pro` or `openmind-local` for `http://127.0.0.1:3333`.</span></div>
|
|
298
298
|
<div class="step"><strong>Preview the dashboard</strong><span>`npx . demo` is keyless. The real runtime dashboard streams local state over SSE.</span></div>
|
|
299
299
|
</div>
|
|
@@ -4,6 +4,7 @@ Listen for Telegram messages and send reactions, voice, images
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import asyncio
|
|
7
|
+
import contextlib
|
|
7
8
|
import os
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from telegram import Update, InputFile, ReactionTypeEmoji
|
|
@@ -30,6 +31,8 @@ class TelegramListener:
|
|
|
30
31
|
self.chat_id = None
|
|
31
32
|
self.user_id = None
|
|
32
33
|
self.last_message_id = None
|
|
34
|
+
self._stop_event = None
|
|
35
|
+
self._stopping = False
|
|
33
36
|
|
|
34
37
|
# Command handler (initialized later with dependencies)
|
|
35
38
|
self.commands = None
|
|
@@ -63,6 +66,8 @@ class TelegramListener:
|
|
|
63
66
|
|
|
64
67
|
async def start(self):
|
|
65
68
|
"""Start listening - blocks forever"""
|
|
69
|
+
self._stop_event = asyncio.Event()
|
|
70
|
+
|
|
66
71
|
# First check environment variable (from secrets.env), then settings
|
|
67
72
|
token = os.environ.get("TELEGRAM_TOKEN") or self.config.settings.get("telegram_token")
|
|
68
73
|
|
|
@@ -178,20 +183,34 @@ class TelegramListener:
|
|
|
178
183
|
) from None
|
|
179
184
|
|
|
180
185
|
# Block forever - keep the bot alive
|
|
181
|
-
|
|
186
|
+
try:
|
|
187
|
+
await self._stop_event.wait()
|
|
188
|
+
except asyncio.CancelledError:
|
|
189
|
+
await self.stop()
|
|
190
|
+
raise
|
|
191
|
+
finally:
|
|
192
|
+
await self.stop()
|
|
182
193
|
|
|
183
194
|
async def stop(self):
|
|
184
195
|
"""Stop Telegram cleanly if it was started."""
|
|
185
|
-
if not self.
|
|
196
|
+
if self._stop_event and not self._stop_event.is_set():
|
|
197
|
+
self._stop_event.set()
|
|
198
|
+
if self._stopping or not self.app:
|
|
186
199
|
return
|
|
200
|
+
self._stopping = True
|
|
201
|
+
app = self.app
|
|
202
|
+
self.app = None
|
|
187
203
|
try:
|
|
188
|
-
if
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
204
|
+
if app.updater and app.updater.running:
|
|
205
|
+
with contextlib.suppress(Exception):
|
|
206
|
+
await app.updater.stop()
|
|
207
|
+
if app.running:
|
|
208
|
+
with contextlib.suppress(Exception):
|
|
209
|
+
await app.stop()
|
|
210
|
+
with contextlib.suppress(Exception):
|
|
211
|
+
await app.shutdown()
|
|
193
212
|
finally:
|
|
194
|
-
self.
|
|
213
|
+
self._stopping = False
|
|
195
214
|
|
|
196
215
|
async def _on_message(self, update: Update, context):
|
|
197
216
|
"""Handle incoming message"""
|
|
@@ -359,12 +378,13 @@ class TelegramListener:
|
|
|
359
378
|
print(f"[Telegram] Send text error: {e}")
|
|
360
379
|
|
|
361
380
|
async def _send_voice_file(self, data: dict):
|
|
362
|
-
"""Send voice file
|
|
381
|
+
"""Send voice file, falling back to audio/text if Telegram rejects it."""
|
|
363
382
|
if not self.app:
|
|
364
383
|
return
|
|
365
384
|
|
|
366
385
|
chat_id = data.get("chat_id", self.chat_id)
|
|
367
386
|
file_path = data.get("file_path", "")
|
|
387
|
+
fallback_text = data.get("fallback_text", "")
|
|
368
388
|
|
|
369
389
|
if not chat_id or not file_path:
|
|
370
390
|
return
|
|
@@ -372,18 +392,32 @@ class TelegramListener:
|
|
|
372
392
|
path = Path(file_path)
|
|
373
393
|
if not path.exists():
|
|
374
394
|
print(f"[Telegram] Voice file not found: {file_path}")
|
|
395
|
+
if fallback_text:
|
|
396
|
+
await self._send_text({"chat_id": chat_id, "text": fallback_text})
|
|
375
397
|
return
|
|
376
398
|
|
|
377
399
|
try:
|
|
378
|
-
|
|
379
|
-
|
|
400
|
+
if path.suffix.lower() == ".ogg":
|
|
401
|
+
with open(path, "rb") as voice_file:
|
|
402
|
+
await self.app.bot.send_voice(
|
|
403
|
+
chat_id=chat_id,
|
|
404
|
+
voice=voice_file,
|
|
405
|
+
caption=data.get("caption", "")
|
|
406
|
+
)
|
|
407
|
+
print(f"[Telegram] Sent voice: {file_path}")
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
with open(path, "rb") as audio_file:
|
|
411
|
+
await self.app.bot.send_audio(
|
|
380
412
|
chat_id=chat_id,
|
|
381
|
-
|
|
413
|
+
audio=audio_file,
|
|
382
414
|
caption=data.get("caption", "")
|
|
383
415
|
)
|
|
384
|
-
print(f"[Telegram] Sent
|
|
416
|
+
print(f"[Telegram] Sent audio fallback: {file_path}")
|
|
385
417
|
except Exception as e:
|
|
386
|
-
print(f"[Telegram] Send voice error: {e}")
|
|
418
|
+
print(f"[Telegram] Send voice/audio error: {e}")
|
|
419
|
+
if fallback_text:
|
|
420
|
+
await self._send_text({"chat_id": chat_id, "text": fallback_text})
|
|
387
421
|
|
|
388
422
|
async def _send_image(self, data: dict):
|
|
389
423
|
"""Send image file"""
|
package/main.py
CHANGED
|
@@ -12,6 +12,7 @@ import argparse
|
|
|
12
12
|
import contextlib
|
|
13
13
|
import json
|
|
14
14
|
import os
|
|
15
|
+
import signal
|
|
15
16
|
import sys
|
|
16
17
|
import traceback
|
|
17
18
|
from pathlib import Path
|
|
@@ -91,6 +92,28 @@ async def main() -> None:
|
|
|
91
92
|
|
|
92
93
|
ai = Self(ROOT)
|
|
93
94
|
webui_task = None
|
|
95
|
+
runtime_task = None
|
|
96
|
+
stop_task = None
|
|
97
|
+
stop_event = asyncio.Event()
|
|
98
|
+
loop = asyncio.get_running_loop()
|
|
99
|
+
|
|
100
|
+
def request_stop(signame: str) -> None:
|
|
101
|
+
if stop_event.is_set():
|
|
102
|
+
return
|
|
103
|
+
print(f"\n[Alive-AI] Stopping ({signame})...")
|
|
104
|
+
stop_event.set()
|
|
105
|
+
|
|
106
|
+
signal_handlers = []
|
|
107
|
+
for signame in ("SIGINT", "SIGTERM"):
|
|
108
|
+
signum = getattr(signal, signame, None)
|
|
109
|
+
if signum is None:
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
loop.add_signal_handler(signum, request_stop, signame)
|
|
113
|
+
signal_handlers.append(signum)
|
|
114
|
+
except (NotImplementedError, RuntimeError, ValueError):
|
|
115
|
+
pass
|
|
116
|
+
|
|
94
117
|
webui_enabled = str(settings.get("WEBUI_ENABLED", os.environ.get("WEBUI_ENABLED", "true"))).lower() != "false"
|
|
95
118
|
webui_port = int(settings.get("WEBUI_PORT", os.environ.get("WEBUI_PORT", "8080")))
|
|
96
119
|
|
|
@@ -108,7 +131,25 @@ async def main() -> None:
|
|
|
108
131
|
print(f"[Alive-AI] WebUI unavailable: {exc}")
|
|
109
132
|
|
|
110
133
|
input_channel = args.input or os.environ.get("ALIVE_AI_INPUT_CHANNEL") or settings.get("INPUT_CHANNEL", "telegram")
|
|
111
|
-
|
|
134
|
+
runtime_task = asyncio.create_task(ai.start(input_channel=input_channel))
|
|
135
|
+
stop_task = asyncio.create_task(stop_event.wait())
|
|
136
|
+
done, _pending = await asyncio.wait(
|
|
137
|
+
{runtime_task, stop_task},
|
|
138
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if stop_task in done:
|
|
142
|
+
if runtime_task and not runtime_task.done():
|
|
143
|
+
runtime_task.cancel()
|
|
144
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
145
|
+
await runtime_task
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
if stop_task and not stop_task.done():
|
|
149
|
+
stop_task.cancel()
|
|
150
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
151
|
+
await stop_task
|
|
152
|
+
await runtime_task
|
|
112
153
|
except KeyboardInterrupt:
|
|
113
154
|
print("\n[Alive-AI] Stopping...")
|
|
114
155
|
except Exception as exc:
|
|
@@ -120,6 +161,17 @@ async def main() -> None:
|
|
|
120
161
|
print("[Alive-AI] Telegram could not start. Check the bot token/network, or run `npx . chat` for terminal mode.")
|
|
121
162
|
sys.exit(1)
|
|
122
163
|
finally:
|
|
164
|
+
for signum in signal_handlers:
|
|
165
|
+
with contextlib.suppress(Exception):
|
|
166
|
+
loop.remove_signal_handler(signum)
|
|
167
|
+
if stop_task and not stop_task.done():
|
|
168
|
+
stop_task.cancel()
|
|
169
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
170
|
+
await stop_task
|
|
171
|
+
if runtime_task and not runtime_task.done():
|
|
172
|
+
runtime_task.cancel()
|
|
173
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
174
|
+
await runtime_task
|
|
123
175
|
with contextlib.suppress(Exception):
|
|
124
176
|
await ai.stop()
|
|
125
177
|
if webui_task:
|
package/output/voice/gtts_tts.py
CHANGED
|
@@ -8,6 +8,7 @@ import asyncio
|
|
|
8
8
|
import re
|
|
9
9
|
import tempfile
|
|
10
10
|
import subprocess
|
|
11
|
+
import warnings
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Optional
|
|
13
14
|
from concurrent.futures import ThreadPoolExecutor
|
|
@@ -113,17 +114,29 @@ class GTTS:
|
|
|
113
114
|
tts.write_to_fp(mp3_buffer)
|
|
114
115
|
mp3_buffer.seek(0)
|
|
115
116
|
|
|
116
|
-
# Convert MP3 to OGG for Telegram
|
|
117
|
-
#
|
|
117
|
+
# Convert MP3 to OGG Opus for Telegram voice notes.
|
|
118
|
+
# pydub's default OGG codec can resolve to Vorbis, which Telegram
|
|
119
|
+
# does not want for voice notes and some ffmpeg builds do not ship.
|
|
118
120
|
try:
|
|
121
|
+
warnings.filterwarnings("ignore", category=SyntaxWarning, module=r"pydub\..*")
|
|
119
122
|
from pydub import AudioSegment
|
|
120
123
|
audio = AudioSegment.from_mp3(mp3_buffer)
|
|
121
124
|
ogg_buffer = io.BytesIO()
|
|
122
|
-
audio.export(
|
|
125
|
+
audio.export(
|
|
126
|
+
ogg_buffer,
|
|
127
|
+
format="ogg",
|
|
128
|
+
codec="libopus",
|
|
129
|
+
bitrate="32k",
|
|
130
|
+
parameters=["-application", "voip"],
|
|
131
|
+
)
|
|
123
132
|
ogg_buffer.seek(0)
|
|
124
133
|
return ogg_buffer.read()
|
|
125
134
|
except ImportError:
|
|
126
|
-
# No pydub - return MP3
|
|
135
|
+
# No pydub - return MP3 and let the sender use send_audio.
|
|
136
|
+
mp3_buffer.seek(0)
|
|
137
|
+
return mp3_buffer.read()
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
print(f"[GTTS] OGG Opus conversion failed, falling back to MP3: {exc}")
|
|
127
140
|
mp3_buffer.seek(0)
|
|
128
141
|
return mp3_buffer.read()
|
|
129
142
|
|
package/package.json
CHANGED