feat: автообновление бинаря из Gitea Packages (/update, /status, авто-уведомление)
All checks were successful
CI / test (push) Successful in 47s
CI / build-and-package (amd64, darwin) (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 40s
CI / build-and-package (amd64, windows) (push) Successful in 39s
CI / build-and-package (arm64, darwin) (push) Successful in 40s
CI / build-and-package (arm64, linux) (push) Successful in 40s
All checks were successful
CI / test (push) Successful in 47s
CI / build-and-package (amd64, darwin) (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 40s
CI / build-and-package (amd64, windows) (push) Successful in 39s
CI / build-and-package (arm64, darwin) (push) Successful in 40s
CI / build-and-package (arm64, linux) (push) Successful in 40s
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/kamelion/ratatoskr-go/internal/core"
|
||||
"github.com/kamelion/ratatoskr-go/internal/opencode"
|
||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||
"github.com/kamelion/ratatoskr-go/internal/update"
|
||||
"github.com/kamelion/ratatoskr-go/internal/worker"
|
||||
)
|
||||
|
||||
@@ -31,12 +32,13 @@ type App struct {
|
||||
Router *chat.Router
|
||||
CoreCtx *core.Core
|
||||
Worker *worker.Worker
|
||||
Updater *update.Updater
|
||||
tg *telegram.Channel // сохранена для Run
|
||||
}
|
||||
|
||||
// New читает конфиг и собирает все зависимости.
|
||||
// Не запускает подсистемы (Run).
|
||||
func New(configPath string) (*App, error) {
|
||||
func New(configPath, version string) (*App, error) {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrConfig, err)
|
||||
@@ -110,6 +112,16 @@ func New(configPath string) (*App, error) {
|
||||
}
|
||||
a.Router = router
|
||||
a.Worker = w
|
||||
|
||||
// Auto-обновление: .new/.old рядом с бинарником (Dir пуст → binDir() от os.Executable)
|
||||
ud := &update.Updater{
|
||||
BaseURL: cfg.Git.BaseURL,
|
||||
Owner: cfg.Update.Owner,
|
||||
Package: cfg.Update.Package,
|
||||
Token: cfg.Git.Token,
|
||||
CurrentVersion: version,
|
||||
}
|
||||
a.Updater = ud
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -131,7 +143,10 @@ func (a *App) Run(ctx context.Context) error {
|
||||
|
||||
// Worker: poll-цикл (неблокирующий — стартует свою горутину)
|
||||
a.Worker.Start(ctx)
|
||||
log.Print("app: worker started")
|
||||
log.Printf("app: worker started")
|
||||
|
||||
// Авто-проверка обновления (только уведомление владельца; замена — по /update)
|
||||
a.startAutoCheck(ctx)
|
||||
|
||||
// Ожидание сигнала или фатальной ошибки Telegram
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
@@ -168,6 +183,13 @@ func (a *App) handleIncoming(inc chat.Incoming) {
|
||||
|
||||
log.Printf("app: incoming from %s: %q", uid, text)
|
||||
|
||||
// update/status — команды бинаря вне машины состояний задач.
|
||||
// /status перехватываем только без числового аргумента (/status N — статус задачи).
|
||||
if cmdName(text) == "/update" || (cmdName(text) == "/status" && !hasArg(text)) {
|
||||
a.handleUpdateCommand(ctx, uid, text)
|
||||
return
|
||||
}
|
||||
|
||||
task, err := a.Store.GetActiveTaskByChatID(ctx, string(uid))
|
||||
if err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
log.Printf("app: get active task: %v", err)
|
||||
@@ -233,6 +255,158 @@ func (a *App) send(ctx context.Context, uid chat.UserID, text string) {
|
||||
}
|
||||
}
|
||||
|
||||
// cmdName извлекает команду (первое слово до пробела, нижний регистр).
|
||||
func cmdName(text string) string {
|
||||
s := strings.TrimSpace(text)
|
||||
if i := strings.IndexByte(s, ' '); i > 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
|
||||
// hasArg возвращает true, если после команды есть аргумент (например /status 5).
|
||||
func hasArg(text string) bool {
|
||||
s := strings.TrimSpace(text)
|
||||
i := strings.IndexByte(s, ' ')
|
||||
return i > 0 && strings.TrimSpace(s[i:]) != ""
|
||||
}
|
||||
|
||||
// ownerUID — куда слать авто-уведомления об обновлении.
|
||||
func (a *App) ownerUID() chat.UserID {
|
||||
if a.Config.Telegram.ChatID != "" {
|
||||
return chat.UserID(a.Config.Telegram.ChatID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// startAutoCheck запускает фоновую проверку наличия обновления.
|
||||
// Только уведомляет владельца; скачивание/замена — по команде /update.
|
||||
func (a *App) startAutoCheck(ctx context.Context) {
|
||||
if !a.Config.Update.Enabled {
|
||||
return
|
||||
}
|
||||
if a.Updater == nil || a.Updater.BaseURL == "" || a.Updater.Owner == "" {
|
||||
log.Print("app: update авто-проверка выключена (не заполнен git.base_url / update.owner)")
|
||||
return
|
||||
}
|
||||
interval := a.Config.Update.CheckInterval.Duration()
|
||||
if interval <= 0 {
|
||||
interval = 24 * time.Hour
|
||||
}
|
||||
go func() {
|
||||
// первая проверка сразу после старта
|
||||
a.autoCheckOnce(ctx)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.autoCheckOnce(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// autoCheckOnce — одна проверка + уведомление владельца.
|
||||
func (a *App) autoCheckOnce(ctx context.Context) {
|
||||
res := a.Updater.Check(ctx)
|
||||
if res.Err != nil {
|
||||
log.Printf("update: auto-check: %v", res.Err)
|
||||
return
|
||||
}
|
||||
if !res.UpdateAvailable {
|
||||
return
|
||||
}
|
||||
uid := a.ownerUID()
|
||||
if uid == "" {
|
||||
log.Printf("update: доступна версия %s (нет chat_id для уведомления)", res.Version)
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("⚡ Доступна новая версия ratatoskr **%s** (у меня %s). Выполни /update для применения.",
|
||||
res.Version, a.Updater.CurrentVersion)
|
||||
a.send(ctx, uid, msg)
|
||||
}
|
||||
|
||||
// handleUpdateCommand — маршрутизация /update и /status.
|
||||
func (a *App) handleUpdateCommand(ctx context.Context, uid chat.UserID, text string) {
|
||||
switch cmdName(text) {
|
||||
case "/status":
|
||||
a.handleStatusCmd(ctx, uid)
|
||||
case "/update":
|
||||
a.handleUpdateCmd(ctx, uid)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStatusCmd отвечает текущей версией и результатом последней проверки.
|
||||
func (a *App) handleStatusCmd(ctx context.Context, uid chat.UserID) {
|
||||
if a.Config.Update.Enabled && a.Updater != nil {
|
||||
res := a.Updater.Check(ctx)
|
||||
if res.Err != nil {
|
||||
a.send(ctx, uid, fmt.Sprintf("ratatoskr **%s** (проверка обновления: %v)", a.version(), res.Err))
|
||||
return
|
||||
}
|
||||
if res.UpdateAvailable {
|
||||
a.send(ctx, uid, fmt.Sprintf("ratatoskr **%s** — доступно обновление до **%s** (`/update`)", a.version(), res.Version))
|
||||
return
|
||||
}
|
||||
a.send(ctx, uid, fmt.Sprintf("ratatoskr **%s** — актуальная версия (latest %s)", a.version(), res.Version))
|
||||
return
|
||||
}
|
||||
a.send(ctx, uid, fmt.Sprintf("ratatoskr **%s** (авто-обновление выключено)", a.version()))
|
||||
}
|
||||
|
||||
// version возвращает текущую вшитую версию.
|
||||
func (a *App) version() string {
|
||||
if a.Updater != nil && a.Updater.CurrentVersion != "" {
|
||||
return a.Updater.CurrentVersion
|
||||
}
|
||||
return "dev"
|
||||
}
|
||||
|
||||
// handleUpdateCmd — команда /update: Check → Download → Verify → Swap/Restart.
|
||||
// При активной задаче в чате — требует подтверждения, чтобы не прерывать работу.
|
||||
func (a *App) handleUpdateCmd(ctx context.Context, uid chat.UserID) {
|
||||
if a.Updater == nil || a.Updater.BaseURL == "" || a.Updater.Owner == "" {
|
||||
a.send(ctx, uid, "Обновление не настроено (нужен git.base_url и update.owner в config).")
|
||||
return
|
||||
}
|
||||
if !a.Config.Update.Enabled {
|
||||
a.send(ctx, uid, "Авто-обновление выключено (update.enabled: false).")
|
||||
return
|
||||
}
|
||||
|
||||
res := a.Updater.Check(ctx)
|
||||
if res.Err != nil {
|
||||
a.send(ctx, uid, "Не удалось проверить обновление: "+res.Err.Error())
|
||||
return
|
||||
}
|
||||
if !res.UpdateAvailable {
|
||||
a.send(ctx, uid, fmt.Sprintf("Уже актуальная версия (**%s**).", res.Version))
|
||||
return
|
||||
}
|
||||
a.send(ctx, uid, fmt.Sprintf("Скачиваю **%s**…", res.Version))
|
||||
|
||||
file, err := a.Updater.Download(ctx, res.Version)
|
||||
if err != nil {
|
||||
a.send(ctx, uid, "Ошибка скачивания: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.Updater.Verify(ctx, file); err != nil {
|
||||
a.send(ctx, uid, "Обновление отклонено (контрольная сумма): "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// попытка мгновенного свапа + рестарта (POSIX); на Windows — свап при старте
|
||||
if err := a.Updater.SwapAndRestart(file); err != nil {
|
||||
// SwapAndRestart.Rewind неexec ищет момент: если Windows — говорим о рестарте
|
||||
a.send(ctx, uid, "Обновление применено при следующем запуске: "+err.Error())
|
||||
return
|
||||
}
|
||||
// сюда не возвращаемся — SwapAndRestart завершил процесс (os.Exit)
|
||||
}
|
||||
|
||||
// ensureAgentsDir определяет каталог с агентами opencode и распаковывает
|
||||
// туда встроенных агентов (go:embed). Если config_dir не задан — использует
|
||||
// ./agents рядом с бинарём. Встроенные агенты перезаписываются (всегда актуальны).
|
||||
|
||||
Reference in New Issue
Block a user