Skip to content

Commit 35abc94

Browse files
authored
feat: add Telegram bot and TV now playing (#84)
- TV status now shows current app, channel, and volume when online - Telegram bot with slash commands (/status, /doctor, /tv, /speedtest, /vpn) - Natural language mode via Claude Haiku for conversational control - Bot setup wizard (homelab bot setup) for token + user config - LaunchAgent daemon support (homelab bot schedule install) - Restricted to authorized Telegram user IDs only
1 parent 068db3c commit 35abc94

File tree

10 files changed

+1207
-3
lines changed

10 files changed

+1207
-3
lines changed
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
using System.ComponentModel;
2+
using System.Diagnostics;
3+
using Spectre.Console;
4+
using Spectre.Console.Cli;
5+
6+
namespace HomeLab.Cli.Commands.Bot;
7+
8+
public class BotScheduleCommand : Command<BotScheduleCommand.Settings>
9+
{
10+
private static readonly string PlistPath = Path.Combine(
11+
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
12+
"Library", "LaunchAgents", "com.homelab.bot.plist");
13+
14+
private const string ExternalDrivePath = "/Volumes/T9";
15+
16+
private static readonly string LogPath = Directory.Exists(ExternalDrivePath)
17+
? Path.Combine(ExternalDrivePath, ".homelab", "bot.log")
18+
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
19+
".homelab", "bot.log");
20+
21+
public class Settings : CommandSettings
22+
{
23+
[CommandArgument(0, "<action>")]
24+
[Description("Action: install, uninstall, or status")]
25+
public string Action { get; set; } = string.Empty;
26+
}
27+
28+
public override int Execute(CommandContext context, Settings settings, CancellationToken cancellationToken)
29+
{
30+
return settings.Action.ToLowerInvariant() switch
31+
{
32+
"install" => Install(),
33+
"uninstall" => Uninstall(),
34+
"status" => Status(),
35+
_ => ShowUsage()
36+
};
37+
}
38+
39+
private static int Install()
40+
{
41+
var homelabPath = FindHomelabBinary();
42+
if (homelabPath == null)
43+
{
44+
AnsiConsole.MarkupLine("[red]Could not find homelab binary.[/]");
45+
return 1;
46+
}
47+
48+
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
49+
50+
var plistContent = $@"<?xml version=""1.0"" encoding=""UTF-8""?>
51+
<!DOCTYPE plist PUBLIC ""-//Apple//DTD PLIST 1.0//EN"" ""http://www.apple.com/DTDs/PropertyList-1.0.dtd"">
52+
<plist version=""1.0"">
53+
<dict>
54+
<key>Label</key>
55+
<string>com.homelab.bot</string>
56+
<key>ProgramArguments</key>
57+
<array>
58+
<string>{homelabPath}</string>
59+
<string>bot</string>
60+
<string>start</string>
61+
</array>
62+
<key>RunAtLoad</key>
63+
<true/>
64+
<key>KeepAlive</key>
65+
<true/>
66+
<key>StandardOutPath</key>
67+
<string>{LogPath}</string>
68+
<key>StandardErrorPath</key>
69+
<string>{LogPath}</string>
70+
<key>EnvironmentVariables</key>
71+
<dict>
72+
<key>PATH</key>
73+
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:{Path.GetDirectoryName(homelabPath)}</string>
74+
</dict>
75+
</dict>
76+
</plist>";
77+
78+
if (File.Exists(PlistPath))
79+
{
80+
RunLaunchctl("unload", PlistPath);
81+
}
82+
83+
Directory.CreateDirectory(Path.GetDirectoryName(PlistPath)!);
84+
File.WriteAllText(PlistPath, plistContent);
85+
86+
var result = RunLaunchctl("load", PlistPath);
87+
if (result != 0)
88+
{
89+
AnsiConsole.MarkupLine("[red]Failed to load LaunchAgent[/]");
90+
return 1;
91+
}
92+
93+
AnsiConsole.MarkupLine("[green]Bot daemon installed[/]");
94+
AnsiConsole.MarkupLine($" Binary: {homelabPath}");
95+
AnsiConsole.MarkupLine($" Plist: {PlistPath}");
96+
AnsiConsole.MarkupLine($" Log: {LogPath}");
97+
AnsiConsole.MarkupLine("[dim]Bot will auto-restart if it crashes.[/]");
98+
99+
return 0;
100+
}
101+
102+
private static int Uninstall()
103+
{
104+
if (!File.Exists(PlistPath))
105+
{
106+
AnsiConsole.MarkupLine("[yellow]Bot daemon not installed[/]");
107+
return 0;
108+
}
109+
110+
RunLaunchctl("unload", PlistPath);
111+
File.Delete(PlistPath);
112+
113+
AnsiConsole.MarkupLine("[green]Bot daemon uninstalled[/]");
114+
return 0;
115+
}
116+
117+
private static int Status()
118+
{
119+
if (!File.Exists(PlistPath))
120+
{
121+
AnsiConsole.MarkupLine("[yellow]Not installed[/]");
122+
AnsiConsole.MarkupLine("[dim]Install with: homelab bot schedule install[/]");
123+
return 0;
124+
}
125+
126+
AnsiConsole.MarkupLine("[green]Installed[/]");
127+
AnsiConsole.MarkupLine($" Plist: {PlistPath}");
128+
129+
try
130+
{
131+
using var process = new Process
132+
{
133+
StartInfo = new ProcessStartInfo
134+
{
135+
FileName = "launchctl",
136+
Arguments = "list com.homelab.bot",
137+
RedirectStandardOutput = true,
138+
RedirectStandardError = true,
139+
UseShellExecute = false
140+
}
141+
};
142+
143+
process.Start();
144+
process.StandardOutput.ReadToEnd();
145+
process.WaitForExit();
146+
147+
AnsiConsole.MarkupLine(process.ExitCode == 0
148+
? " Status: [green]running[/]"
149+
: " Status: [yellow]not loaded[/]");
150+
}
151+
catch
152+
{
153+
AnsiConsole.MarkupLine(" Status: [dim]unknown[/]");
154+
}
155+
156+
if (File.Exists(LogPath))
157+
{
158+
var logInfo = new FileInfo(LogPath);
159+
AnsiConsole.MarkupLine($" Log: {LogPath} ({logInfo.Length / 1024}KB)");
160+
}
161+
162+
return 0;
163+
}
164+
165+
private static int ShowUsage()
166+
{
167+
AnsiConsole.MarkupLine("[red]Unknown action. Use: install, uninstall, or status[/]");
168+
return 1;
169+
}
170+
171+
private static string? FindHomelabBinary()
172+
{
173+
var candidates = new[]
174+
{
175+
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "homelab"),
176+
"/usr/local/bin/homelab",
177+
"/opt/homebrew/bin/homelab"
178+
};
179+
180+
foreach (var path in candidates)
181+
{
182+
if (File.Exists(path))
183+
{
184+
return path;
185+
}
186+
}
187+
188+
try
189+
{
190+
using var process = new Process
191+
{
192+
StartInfo = new ProcessStartInfo
193+
{
194+
FileName = "which",
195+
Arguments = "homelab",
196+
RedirectStandardOutput = true,
197+
UseShellExecute = false
198+
}
199+
};
200+
201+
process.Start();
202+
var output = process.StandardOutput.ReadToEnd().Trim();
203+
process.WaitForExit();
204+
205+
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output))
206+
{
207+
return output;
208+
}
209+
}
210+
catch { }
211+
212+
return null;
213+
}
214+
215+
private static int RunLaunchctl(string action, string plistPath)
216+
{
217+
try
218+
{
219+
using var process = new Process
220+
{
221+
StartInfo = new ProcessStartInfo
222+
{
223+
FileName = "launchctl",
224+
Arguments = $"{action} {plistPath}",
225+
RedirectStandardOutput = true,
226+
RedirectStandardError = true,
227+
UseShellExecute = false
228+
}
229+
};
230+
231+
process.Start();
232+
process.WaitForExit();
233+
return process.ExitCode;
234+
}
235+
catch
236+
{
237+
return 1;
238+
}
239+
}
240+
}

0 commit comments

Comments
 (0)