summaryrefslogtreecommitdiff
path: root/bot/main.go
diff options
context:
space:
mode:
authorprospanclaudebot <[email protected]>2026-07-10 22:10:31 +0000
committerprospanclaudebot <[email protected]>2026-07-10 22:10:31 +0000
commit966b62d9bd7e483f1e9484a5a99d8c2db475171c (patch)
treedf3a27730963d68d515272b4246dc79d96945744 /bot/main.go
downloadpacan-git-main.tar.gz
pacan-git-main.zip
initial import: pacan-gitHEADmain
Diffstat (limited to 'bot/main.go')
-rw-r--r--bot/main.go464
1 files changed, 464 insertions, 0 deletions
diff --git a/bot/main.go b/bot/main.go
new file mode 100644
index 0000000..61e1621
--- /dev/null
+++ b/bot/main.go
@@ -0,0 +1,464 @@
+// Пацанский Гит — Telegram-бот. Репозитории под неймспейсом юзера (<user>/<repo>.git),
+// управление SSH-ключами (генерит authorized_keys для sshd), вайтлист по чату.
+// Stdlib-only.
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+var (
+ token = os.Getenv("BOT_TOKEN")
+ reposDir = envDefault("REPOS_DIR", "/repos")
+ keysDir = envDefault("KEYS_DIR", "/keys")
+ publicURL = strings.TrimRight(envDefault("PUBLIC_URL", "http://localhost"), "/")
+ sshHost = os.Getenv("SSH_HOST") // напр. [email protected] (для ssh clone-url; пусто = не показывать)
+ nsCmd = envDefault("GIT_NS", "git-ns") // форс-команда в authorized_keys (в докере "git-ns" на PATH, живьём — абс.путь)
+ admins = parseAdmins(os.Getenv("ADMINS"))
+ whitelistChat = os.Getenv("WHITELIST_CHAT")
+ api = "https://api.telegram.org/bot" + token
+ nameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`)
+ userRe = regexp.MustCompile(`[^a-z0-9._-]+`)
+ sshKeyRe = regexp.MustCompile(`^(ssh-(rsa|ed25519|dss)|ecdsa-sha2-\S+)\s+[A-Za-z0-9+/=]+(\s+\S+)?$`)
+ mu sync.Mutex // защищает файловые операции с ключами/authorized_keys
+)
+
+func envDefault(k, d string) string {
+ if v := os.Getenv(k); v != "" {
+ return v
+ }
+ return d
+}
+
+func parseAdmins(s string) map[int64]bool {
+ m := map[int64]bool{}
+ for _, p := range strings.Split(s, ",") {
+ if id, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64); err == nil {
+ m[id] = true
+ }
+ }
+ return m
+}
+
+type Update struct {
+ UpdateID int64 `json:"update_id"`
+ Message *struct {
+ From struct {
+ ID int64 `json:"id"`
+ Username string `json:"username"`
+ } `json:"from"`
+ Chat struct {
+ ID int64 `json:"id"`
+ } `json:"chat"`
+ Text string `json:"text"`
+ } `json:"message"`
+}
+
+func tg(method string, params url.Values) ([]byte, error) {
+ resp, err := http.PostForm(api+"/"+method, params)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ return io.ReadAll(resp.Body)
+}
+
+func send(chat int64, text string) {
+ tg("sendMessage", url.Values{"chat_id": {strconv.FormatInt(chat, 10)},
+ "text": {text}, "parse_mode": {"HTML"}, "disable_web_page_preview": {"true"}})
+}
+
+// имя-неймспейс юзера: @username в нижнем регистре, иначе u<id>
+func nsOf(username string, id int64) string {
+ u := strings.ToLower(strings.TrimSpace(username))
+ if u == "" {
+ return "u" + strconv.FormatInt(id, 10)
+ }
+ u = userRe.ReplaceAllString(u, "-")
+ if u == "" {
+ return "u" + strconv.FormatInt(id, 10)
+ }
+ return u
+}
+
+func repoPath(ns, name string) string { return filepath.Join(reposDir, ns, name+".git") }
+
+// ---- вайтлист по членству в чате ----
+type memEntry struct {
+ ok bool
+ exp time.Time
+}
+
+var (
+ memCache = map[int64]memEntry{}
+ memCacheMu sync.Mutex
+)
+
+func authorized(id int64) bool {
+ if admins[id] {
+ return true
+ }
+ if whitelistChat != "" {
+ return isChatMember(id)
+ }
+ return len(admins) == 0
+}
+
+func isChatMember(id int64) bool {
+ memCacheMu.Lock()
+ if e, ok := memCache[id]; ok && time.Now().Before(e.exp) {
+ memCacheMu.Unlock()
+ return e.ok
+ }
+ memCacheMu.Unlock()
+ ok := false
+ if b, err := tg("getChatMember", url.Values{"chat_id": {whitelistChat}, "user_id": {strconv.FormatInt(id, 10)}}); err == nil {
+ var r struct {
+ OK bool `json:"ok"`
+ Result struct {
+ Status string `json:"status"`
+ } `json:"result"`
+ }
+ if json.Unmarshal(b, &r) == nil && r.OK {
+ switch r.Result.Status {
+ case "creator", "administrator", "member", "restricted":
+ ok = true
+ }
+ }
+ }
+ memCacheMu.Lock()
+ memCache[id] = memEntry{ok: ok, exp: time.Now().Add(2 * time.Minute)}
+ memCacheMu.Unlock()
+ return ok
+}
+
+func main() {
+ if token == "" {
+ log.Fatal("BOT_TOKEN не задан")
+ }
+ os.MkdirAll(reposDir, 0o755)
+ os.MkdirAll(filepath.Join(keysDir, "users"), 0o700)
+ rebuildAuthorizedKeys()
+ log.Printf("gitbot up · repos=%s · keys=%s · public=%s · whitelist=%s", reposDir, keysDir, publicURL, whitelistChat)
+ var offset int64
+ for {
+ b, err := tg("getUpdates", url.Values{"offset": {strconv.FormatInt(offset+1, 10)}, "timeout": {"30"}})
+ if err != nil {
+ time.Sleep(3 * time.Second)
+ continue
+ }
+ var r struct {
+ OK bool `json:"ok"`
+ Result []Update `json:"result"`
+ }
+ if json.Unmarshal(b, &r) != nil || !r.OK {
+ time.Sleep(2 * time.Second)
+ continue
+ }
+ for _, u := range r.Result {
+ offset = u.UpdateID
+ if u.Message != nil && u.Message.Text != "" {
+ handle(u)
+ }
+ }
+ }
+}
+
+func handle(u Update) {
+ chat, uid, uname := u.Message.Chat.ID, u.Message.From.ID, u.Message.From.Username
+ ns := nsOf(uname, uid)
+ fields := strings.Fields(strings.TrimSpace(u.Message.Text))
+ if len(fields) == 0 {
+ return
+ }
+ cmd := fields[0]
+ if i := strings.Index(cmd, "@"); i >= 0 {
+ cmd = cmd[:i]
+ }
+ // для команд с многострочным аргументом (ключи) берём полный текст после команды
+ rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(u.Message.Text), fields[0]))
+ args := fields[1:]
+
+ switch cmd {
+ case "/start", "/help":
+ send(chat, helpText())
+ case "/new":
+ if !authorized(uid) {
+ send(chat, "⛔ Только участники чата.")
+ return
+ }
+ if len(args) < 1 || !nameRe.MatchString(args[0]) {
+ send(chat, "Использование: <code>/new имя [описание]</code>")
+ return
+ }
+ if err := createRepo(ns, args[0], strings.Join(args[1:], " ")); err != nil {
+ send(chat, "❌ "+err.Error())
+ return
+ }
+ send(chat, repoInfo(ns, args[0], "✅ Репо создан.\n"))
+ case "/list":
+ send(chat, listAll())
+ case "/mine":
+ send(chat, listMine(ns))
+ case "/info":
+ if len(args) < 1 {
+ send(chat, "Использование: <code>/info имя</code> или <code>/info юзер/имя</code>")
+ return
+ }
+ o, n := ns, args[0]
+ if strings.Contains(args[0], "/") {
+ p := strings.SplitN(args[0], "/", 2)
+ o, n = p[0], strings.TrimSuffix(p[1], ".git")
+ }
+ send(chat, repoInfo(o, n, ""))
+ case "/del", "/rm":
+ if !authorized(uid) {
+ send(chat, "⛔ Только участники чата.")
+ return
+ }
+ if len(args) < 1 || !nameRe.MatchString(args[0]) {
+ send(chat, "Использование: <code>/del имя confirm</code> (только своё)")
+ return
+ }
+ if len(args) < 2 || args[1] != "confirm" {
+ send(chat, fmt.Sprintf("⚠ Удалить <b>%s/%s</b>? Подтверди: <code>/del %s confirm</code>", ns, args[0], args[0]))
+ return
+ }
+ if err := delRepo(ns, args[0]); err != nil {
+ send(chat, "❌ "+err.Error())
+ return
+ }
+ send(chat, "🗑 Удалён <b>"+ns+"/"+args[0]+"</b>.")
+ case "/addkey":
+ if !authorized(uid) {
+ send(chat, "⛔ Только участники чата.")
+ return
+ }
+ if err := addKey(ns, rest); err != nil {
+ send(chat, "❌ "+err.Error())
+ return
+ }
+ s := ""
+ if sshHost != "" {
+ s = fmt.Sprintf("\nSSH clone: <code>%s:%s/имя.git</code>", sshHost, ns)
+ }
+ send(chat, "🔑 Ключ добавлен."+s)
+ case "/keys":
+ send(chat, listKeys(ns))
+ case "/delkey":
+ if len(args) < 1 {
+ send(chat, "Использование: <code>/delkey N</code> (номер из /keys)")
+ return
+ }
+ n, _ := strconv.Atoi(args[0])
+ if err := delKey(ns, n); err != nil {
+ send(chat, "❌ "+err.Error())
+ return
+ }
+ send(chat, "🔑 Ключ #"+args[0]+" удалён.")
+ }
+}
+
+func createRepo(ns, name, desc string) error {
+ p := repoPath(ns, name)
+ if _, err := os.Stat(p); err == nil {
+ return fmt.Errorf("репо уже есть")
+ }
+ os.MkdirAll(filepath.Dir(p), 0o755)
+ if out, err := exec.Command("git", "init", "--bare", "--initial-branch=main", p).CombinedOutput(); err != nil {
+ return fmt.Errorf("git init: %s", strings.TrimSpace(string(out)))
+ }
+ if desc == "" {
+ desc = name
+ }
+ os.WriteFile(filepath.Join(p, "description"), []byte(desc+"\n"), 0o644)
+ os.WriteFile(filepath.Join(p, "git-daemon-export-ok"), []byte(""), 0o644)
+ exec.Command("git", "--git-dir", p, "config", "http.receivepack", "true").Run()
+ return nil
+}
+
+func delRepo(ns, name string) error {
+ p := repoPath(ns, name)
+ if _, err := os.Stat(p); err != nil {
+ return fmt.Errorf("нет такого репо в твоём неймспейсе")
+ }
+ return os.RemoveAll(p)
+}
+
+func reposOf(ns string) []string {
+ ents, _ := os.ReadDir(filepath.Join(reposDir, ns))
+ var out []string
+ for _, e := range ents {
+ if e.IsDir() && strings.HasSuffix(e.Name(), ".git") {
+ out = append(out, strings.TrimSuffix(e.Name(), ".git"))
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+func listMine(ns string) string {
+ rs := reposOf(ns)
+ if len(rs) == 0 {
+ return "У тебя (<b>" + ns + "</b>) нет репо. Создай: <code>/new имя</code>"
+ }
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "📦 Твои репо (%s): %d\n", ns, len(rs))
+ for _, n := range rs {
+ sb.WriteString("• " + n + "\n")
+ }
+ return sb.String()
+}
+
+func listAll() string {
+ ents, _ := os.ReadDir(reposDir)
+ var owners []string
+ for _, e := range ents {
+ if e.IsDir() {
+ owners = append(owners, e.Name())
+ }
+ }
+ sort.Strings(owners)
+ var sb strings.Builder
+ total := 0
+ for _, o := range owners {
+ rs := reposOf(o)
+ if len(rs) == 0 {
+ continue
+ }
+ total += len(rs)
+ sb.WriteString("<b>" + o + "/</b>\n")
+ for _, n := range rs {
+ sb.WriteString(" • " + n + "\n")
+ }
+ }
+ if total == 0 {
+ return "Репозиториев пока нет."
+ }
+ return fmt.Sprintf("📦 Всего репо: %d\n", total) + sb.String()
+}
+
+func repoInfo(ns, name, prefix string) string {
+ p := repoPath(ns, name)
+ if _, err := os.Stat(p); err != nil {
+ return "нет такого репо"
+ }
+ desc, _ := os.ReadFile(filepath.Join(p, "description"))
+ s := fmt.Sprintf("%s<b>%s/%s</b>\n%s\nHTTP: <code>%s/%s/%s.git</code>\nбраузить: %s/%s/%s/",
+ prefix, ns, name, strings.TrimSpace(string(desc)), publicURL, ns, name, publicURL, ns, name)
+ if sshHost != "" {
+ s += fmt.Sprintf("\nSSH: <code>%s:%s/%s.git</code>", sshHost, ns, name)
+ }
+ return s
+}
+
+// ---- SSH-ключи ----
+func userKeysFile(ns string) string { return filepath.Join(keysDir, "users", ns) }
+
+func readKeys(ns string) []string {
+ b, _ := os.ReadFile(userKeysFile(ns))
+ var out []string
+ for _, l := range strings.Split(string(b), "\n") {
+ if strings.TrimSpace(l) != "" {
+ out = append(out, strings.TrimSpace(l))
+ }
+ }
+ return out
+}
+
+func addKey(ns, key string) error {
+ key = strings.TrimSpace(strings.ReplaceAll(key, "\n", " "))
+ key = strings.Join(strings.Fields(key), " ")
+ if !sshKeyRe.MatchString(key) {
+ return fmt.Errorf("не похоже на ssh-pubkey (ssh-ed25519/ssh-rsa/ecdsa...)")
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ keys := readKeys(ns)
+ for _, k := range keys {
+ if strings.Fields(k)[1] == strings.Fields(key)[1] {
+ return fmt.Errorf("такой ключ уже добавлен")
+ }
+ }
+ keys = append(keys, key)
+ os.WriteFile(userKeysFile(ns), []byte(strings.Join(keys, "\n")+"\n"), 0o600)
+ rebuildAuthorizedKeys()
+ return nil
+}
+
+func delKey(ns string, n int) error {
+ mu.Lock()
+ defer mu.Unlock()
+ keys := readKeys(ns)
+ if n < 1 || n > len(keys) {
+ return fmt.Errorf("нет ключа #%d (см. /keys)", n)
+ }
+ keys = append(keys[:n-1], keys[n:]...)
+ os.WriteFile(userKeysFile(ns), []byte(strings.Join(keys, "\n")+"\n"), 0o600)
+ rebuildAuthorizedKeys()
+ return nil
+}
+
+func listKeys(ns string) string {
+ keys := readKeys(ns)
+ if len(keys) == 0 {
+ return "У тебя нет ключей. Добавь: <code>/addkey ssh-ed25519 AAAA... comment</code>"
+ }
+ var sb strings.Builder
+ sb.WriteString("🔑 Твои ключи:\n")
+ for i, k := range keys {
+ f := strings.Fields(k)
+ typ, blob := f[0], f[1]
+ short := blob
+ if len(blob) > 20 {
+ short = blob[:10] + "…" + blob[len(blob)-8:]
+ }
+ cmt := ""
+ if len(f) > 2 {
+ cmt = " " + strings.Join(f[2:], " ")
+ }
+ fmt.Fprintf(&sb, "%d. %s %s%s\n", i+1, typ, short, cmt)
+ }
+ sb.WriteString("Удалить: <code>/delkey N</code>")
+ return sb.String()
+}
+
+// authorized_keys для sshd: каждый ключ -> git-shell-обёртка, ограниченная неймспейсом юзера
+func rebuildAuthorizedKeys() {
+ mu2 := &mu
+ _ = mu2
+ ents, _ := os.ReadDir(filepath.Join(keysDir, "users"))
+ var sb strings.Builder
+ for _, e := range ents {
+ ns := e.Name()
+ for _, k := range readKeys(ns) {
+ fmt.Fprintf(&sb, `command="%s %s",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding %s`+"\n", nsCmd, ns, k)
+ }
+ }
+ os.WriteFile(filepath.Join(keysDir, "authorized_keys"), []byte(sb.String()), 0o600)
+}
+
+func helpText() string {
+ s := "<b>🤖 Пацанский Гит — бот</b>\nРепо под твоим неймспейсом.\n\n" +
+ "<b>Репо:</b>\n/new имя [описание] · /mine · /list · /info имя · /del имя confirm\n\n" +
+ "<b>SSH-ключи:</b>\n/addkey ssh-ed25519 AAAA... · /keys · /delkey N\n\n" +
+ "HTTP: <code>" + publicURL + "/юзер/имя.git</code>"
+ if sshHost != "" {
+ s += "\nSSH: <code>" + sshHost + ":юзер/имя.git</code>"
+ }
+ return s
+}