# ______ ___ ___ _ _ # ____ | ___ \ | \/ | | | | | # / __ \| |_/ / _| . . | ___ __| |_ _| | ___ # / / _` | __/ | | | |\/| |/ _ \ / _` | | | | |/ _ \ # | | (_| | | | |_| | | | | (_) | (_| | |_| | | __/ # \ \__,_\_| \__, \_| |_/\___/ \__,_|\__,_|_|\___| # \____/ __/ | # |___/ # На модуль распространяется лицензия "GNU General Public License v3.0" # https://github.com/all-licenses/GNU-General-Public-License-v3.0 # meta developer: @pymodule # meta fhsdesc: tool, tools, github, info, inline from .. import loader, utils from ..inline import InlineCall import logging import asyncio import urllib.request import json from datetime import datetime, timedelta @loader.tds class GitHubInfoMod(loader.Module): """GitHub user information""" strings = { "name": "GitHubInfo", "no_username": "❗ Provide a GitHub username.", "user_not_found": "🚫 User not found: {}", "api_error": "⚠ GitHub API error: {msg}", "no_activity": "🕸 No recent activity from {}", "no_contrib": "📭 No contribution data.", "no_repos": "📭 No public repositories.", "no_orgs": "📭 Not a member of any organizations.", "no_title": "No title", "no_desc": "No description", "not_specified": "Not specified", "more_commits": " ... and {} more\n", "hireable_yes": "Yes", "hireable_no": "No", "menu_text": "Choose a section:", "btn_activity": "🔥 Activity", "btn_contrib": "📊 Contributions", "btn_repos": "📦 Repositories", "btn_orgs": "🏛 Organizations", "btn_back": "← Back to profile", "profile_header": "Profile {username}\n\n", "profile_text": ( "👤 Name: {name}\n" "🏷 Login: {login}\n" "📝 Bio: {bio}\n" "🏢 Company: {company}\n" "📍 Location: {location}\n" "📧 Email: {email}\n" "🔗 Website: {blog}\n" "🐦 Twitter: {twitter}\n" "💼 Hireable: {hireable}\n" "📊 Type: {type}\n" "📦 Public repos: {repos}\n" "⭐ Public gists: {gists}\n" "👥 Followers: {followers}\n" "👣 Following: {following}\n" "🕐 Created: {created}\n" "🕐 Updated: {updated}" ), "activity_header": "Recent activity {username}\n\n", "push_header": "🔨 Pushed to {branch}{repo}\n", "push_no_commits": "🔨 Pushed (no details) to {branch}{repo}\n", "commit_line": "• {sha}: {message}\n", "create_branch": "✨ Created branch {ref} in {repo}\n", "create_tag": "✨ Created tag {ref} in {repo}\n", "create_repo": "✨ Created repository {repo}\n", "pr_opened": "🔄 Opened PR #{} {title}\n", "pr_closed": "🔄 Closed PR #{} {title}\n", "pr_merged": "🔄 Merged PR #{} {title}\n", "issue_opened": "❗ Opened issue #{} {title}\n", "issue_closed": "❗ Closed issue #{} {title}\n", "star": "⭐ Starred {repo}\n", "fork": "⑂ Forked {fork}\n", "other": "⚡ {event} in {repo}\n", "repos_header": "Top repositories by stars {username}\n\n", "repo_line": "⭐ {stars} | {name} — {desc}\nLanguage: {lang} | Forks: {forks}\n\n", "orgs_header": "Organizations {username}\n\n", "org_line": "• {login} — {desc}\n", "contrib_header": "Contribution graph (last year) {username}\n", "contrib_footer": "\n⬛ = 0, 🟩 = 1+ contributions", } strings_ru = { "_cls_doc": "Информация о GitHub-пользователе", "no_username": "❗ Укажи GitHub username.", "user_not_found": "🚫 Пользователь не найден: {}", "api_error": "⚠ Ошибка GitHub API: {msg}", "no_activity": "🕸 Нет недавней активности у {}", "no_contrib": "📭 Нет данных о контрибуциях.", "no_repos": "📭 Нет публичных репозиториев.", "no_orgs": "📭 Не состоит в организациях.", "no_title": "Без названия", "no_desc": "Без описания", "not_specified": "Не указано", "more_commits": " ... и ещё {}\n", "hireable_yes": "Да", "hireable_no": "Нет", "menu_text": "Выбери раздел:", "btn_activity": "🔥 Активность", "btn_contrib": "📊 Контрибы", "btn_repos": "📦 Репозитории", "btn_orgs": "🏛 Организации", "btn_back": "← Назад к профилю", "profile_header": "Профиль {username}\n\n", "profile_text": ( "👤 Имя: {name}\n" "🏷 Логин: {login}\n" "📝 Био: {bio}\n" "🏢 Компания: {company}\n" "📍 Локация: {location}\n" "📧 Email: {email}\n" "🔗 Сайт: {blog}\n" "🐦 Twitter: {twitter}\n" "💼 Доступен для найма: {hireable}\n" "📊 Тип аккаунта: {type}\n" "📦 Публичные репозитории: {repos}\n" "⭐ Публичные гисты: {gists}\n" "👥 Подписчики: {followers}\n" "👣 Подписки: {following}\n" "🕐 Создан: {created}\n" "🕐 Обновлён: {updated}" ), "activity_header": "Последняя активность {username}\n\n", "push_header": "🔨 Запушил в {branch}{repo}\n", "push_no_commits": "🔨 Запушил (без деталей) в {branch}{repo}\n", "commit_line": "• {sha}: {message}\n", "create_branch": "✨ Создал ветку {ref} в {repo}\n", "create_tag": "✨ Создал тег {ref} в {repo}\n", "create_repo": "✨ Создал репозиторий {repo}\n", "pr_opened": "🔄 Открыл PR #{} {title}\n", "pr_closed": "🔄 Закрыл PR #{} {title}\n", "pr_merged": "🔄 Замержил PR #{} {title}\n", "issue_opened": "❗ Открыл issue #{} {title}\n", "issue_closed": "❗ Закрыл issue #{} {title}\n", "star": "⭐ Добавил в избранное {repo}\n", "fork": "⑂ Форкнул {fork}\n", "other": "⚡ {event} в {repo}\n", "repos_header": "Топ репозитории по звёздам {username}\n\n", "repo_line": "⭐ {stars} | {name} — {desc}\nЯзык: {lang} | Форков: {forks}\n\n", "orgs_header": "Организации {username}\n\n", "org_line": "• {login} — {desc}\n", "contrib_header": "График контрибуций (последний год) {username}\n", "contrib_footer": "\n⬛ = 0, 🟩 = 1+ контрибуций", } def __init__(self): self.logger = logging.getLogger(__name__) async def github_fetch(self, url, github_api=True): headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", "Accept": "application/vnd.github+json" if github_api else "application/json", "X-GitHub-Api-Version": "2022-11-28", } req = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen(req, timeout=15) as resp: raw = resp.read().decode("utf-8") return json.loads(raw) if raw else {} except Exception as e: self.logger.error(f"[GitHub] {e}") return {"message": str(e)} @loader.command(ru_doc="{username без @} — Информация о GitHub пользователе") async def github(self, message): """{username without @} — GitHub user information""" username = utils.get_args_raw(message) if not username: await utils.answer(message, self.strings("no_username")) return user_data = await self.github_fetch(f"https://api.github.com/users/{username}") if "message" in user_data: await utils.answer(message, self.strings("user_not_found").format(username)) return hireable = self.strings("hireable_yes") if user_data.get("hireable") else self.strings("hireable_no") profile_text = ( self.strings("profile_header").format(url=user_data["html_url"], username=username) + self.strings("profile_text").format( name=user_data.get("name") or self.strings("not_specified"), login=username, bio=user_data.get("bio") or self.strings("no_desc"), company=user_data.get("company") or self.strings("not_specified"), location=user_data.get("location") or self.strings("not_specified"), email=user_data.get("email") or self.strings("not_specified"), blog=user_data.get("blog") or self.strings("not_specified"), twitter=user_data.get("twitter_username") or self.strings("not_specified"), hireable=hireable, type=user_data.get("type", "User"), repos=user_data.get("public_repos", 0), gists=user_data.get("public_gists", 0), followers=user_data.get("followers", 0), following=user_data.get("following", 0), created=user_data.get("created_at", "")[:10], updated=user_data.get("updated_at", "")[:10], ) + "\n" + self.strings("menu_text") ) await self.inline.form( message=message, text=profile_text, reply_markup=[ [{"text": self.strings("btn_activity"), "callback": self._activity, "args": (username,)}], [{"text": self.strings("btn_contrib"), "callback": self._contrib, "args": (username,)}, {"text": self.strings("btn_repos"), "callback": self._repos, "args": (username,)}], [{"text": self.strings("btn_orgs"), "callback": self._orgs, "args": (username,)}], ], ttl=10 * 60, ) async def _profile(self, call: InlineCall, username: str): # Этот метод теперь используется только для возврата к профилю data = await self.github_fetch(f"https://api.github.com/users/{username}") if "message" in data: await call.edit(self.strings("api_error").format(msg=data["message"])) return hireable = self.strings("hireable_yes") if data.get("hireable") else self.strings("hireable_no") profile_text = ( self.strings("profile_header").format(url=data["html_url"], username=username) + self.strings("profile_text").format( name=data.get("name") or self.strings("not_specified"), login=username, bio=data.get("bio") or self.strings("no_desc"), company=data.get("company") or self.strings("not_specified"), location=data.get("location") or self.strings("not_specified"), email=data.get("email") or self.strings("not_specified"), blog=data.get("blog") or self.strings("not_specified"), twitter=data.get("twitter_username") or self.strings("not_specified"), hireable=hireable, type=data.get("type", "User"), repos=data.get("public_repos", 0), gists=data.get("public_gists", 0), followers=data.get("followers", 0), following=data.get("following", 0), created=data.get("created_at", "")[:10], updated=data.get("updated_at", "")[:10], ) + "\n" + self.strings("menu_text") ) await call.edit( text=profile_text, reply_markup=[ [{"text": self.strings("btn_activity"), "callback": self._activity, "args": (username,)}], [{"text": self.strings("btn_contrib"), "callback": self._contrib, "args": (username,)}, {"text": self.strings("btn_repos"), "callback": self._repos, "args": (username,)}], [{"text": self.strings("btn_orgs"), "callback": self._orgs, "args": (username,)}], ] ) async def _activity(self, call: InlineCall, username: str): events = await self.github_fetch(f"https://api.github.com/users/{username}/events?per_page=40") if "message" in events: await call.edit(self.strings("api_error").format(msg=events["message"]), reply_markup=[[{"text": self.strings("btn_back"), "callback": self._profile, "args": (username,)}]]) return if not events: await call.edit(self.strings("no_activity").format(username=username), reply_markup=[[{"text": self.strings("btn_back"), "callback": self._profile, "args": (username,)}]]) return lines = [self.strings("activity_header").format(username=username)] seen_repos = set() for event in events[:25]: etype = event["type"] repo = event["repo"]["name"] if repo in seen_repos and len(lines) > 20: continue payload = event.get("payload", {}) if etype == "PushEvent": branch = payload.get("ref", "refs/heads/main").replace("refs/heads/", "") commits = payload.get("commits", []) if commits: lines.append(self.strings("push_header").format(branch=branch, repo=repo)) for commit in commits[:5]: sha = commit["sha"][:7] message = commit["message"].split("\n")[0][:100] if len(commit["message"].split("\n")[0]) > 100: message += "..." url = f"https://github.com/{repo}/commit/{commit['sha']}" lines.append(self.strings("commit_line").format(url=url, sha=sha, message=message)) if len(commits) > 5: lines.append(self.strings("more_commits").format(len(commits)-5)) else: lines.append(self.strings("push_no_commits").format(branch=branch, repo=repo)) seen_repos.add(repo) elif etype == "CreateEvent": ref_type = payload.get("ref_type") ref = payload.get("ref") or "" if ref_type == "branch": lines.append(self.strings("create_branch").format(ref=ref, repo=repo)) elif ref_type == "tag": lines.append(self.strings("create_tag").format(ref=ref, repo=repo)) elif ref_type == "repository": lines.append(self.strings("create_repo").format(repo=repo)) elif etype == "PullRequestEvent": pr = payload.get("pull_request", {}) number = pr.get("number", "?") title = pr.get("title") or self.strings("no_title") url = pr.get("html_url") or f"https://github.com/{repo}" action = payload.get("action") if action == "closed" and pr.get("merged"): lines.append(self.strings("pr_merged").format(url=url, number=number, title=title)) elif action == "opened": lines.append(self.strings("pr_opened").format(url=url, number=number, title=title)) elif action == "closed": lines.append(self.strings("pr_closed").format(url=url, number=number, title=title)) elif etype == "IssuesEvent": issue = payload.get("issue", {}) number = issue.get("number", "?") title = issue.get("title") or self.strings("no_title") url = issue.get("html_url") or f"https://github.com/{repo}" action = payload.get("action") if action == "opened": lines.append(self.strings("issue_opened").format(url=url, number=number, title=title)) elif action == "closed": lines.append(self.strings("issue_closed").format(url=url, number=number, title=title)) elif etype == "WatchEvent": lines.append(self.strings("star").format(repo=repo)) elif etype == "ForkEvent": fork = payload.get("forkee", {}).get("full_name", "unknown") lines.append(self.strings("fork").format(fork=fork)) else: event_name = etype.replace("Event", "") lines.append(self.strings("other").format(event=event_name, repo=repo)) await call.edit( text="".join(lines), reply_markup=[[{"text": self.strings("btn_back"), "callback": self._profile, "args": (username,)}]] ) async def _repos(self, call: InlineCall, username: str): repos = await self.github_fetch(f"https://api.github.com/users/{username}/repos?sort=stars&per_page=10") if "message" in repos: await call.edit(self.strings("api_error").format(msg=repos["message"]), reply_markup=[[{"text": self.strings("btn_back"), "callback": self._profile, "args": (username,)}]]) return if not repos: await call.edit(self.strings("no_repos"), reply_markup=[[{"text": self.strings("btn_back"), "callback": self._profile, "args": (username,)}]]) return lines = [self.strings("repos_header").format(username=username)] for repo in repos[:10]: lines.append(self.strings("repo_line").format( stars=repo.get("stargazers_count", 0), url=repo["html_url"], name=repo["name"], desc=repo.get("description") or self.strings("no_desc"), lang=repo.get("language") or self.strings("not_specified"), forks=repo.get("forks_count", 0), )) await call.edit( text="".join(lines), reply_markup=[[{"text": self.strings("btn_back"), "callback": self._profile, "args": (username,)}]] ) async def _orgs(self, call: InlineCall, username: str): orgs = await self.github_fetch(f"https://api.github.com/users/{username}/orgs") if "message" in orgs: await call.edit(self.strings("api_error").format(msg=orgs["message"]), reply_markup=[[{"text": self.strings("btn_back"), "callback": self._profile, "args": (username,)}]]) return if not orgs: await call.edit(self.strings("no_orgs"), reply_markup=[[{"text": self.strings("btn_back"), "callback": self._profile, "args": (username,)}]]) return lines = [self.strings("orgs_header").format(username=username)] for org in orgs: lines.append(self.strings("org_line").format( url=f"https://github.com/{org['login']}", login=org["login"], desc=org.get("description") or self.strings("no_desc"), )) await call.edit( text="".join(lines), reply_markup=[[{"text": self.strings("btn_back"), "callback": self._profile, "args": (username,)}]] ) async def _contrib(self, call: InlineCall, username: str): data = await self.github_fetch(f"https://github-contributions-api.deno.dev/{username}.json", github_api=False) if not data or not data.get("contributions"): await call.edit(self.strings("no_contrib"), reply_markup=[[{"text": self.strings("btn_back"), "callback": self._profile, "args": (username,)}]]) return raw_days = [] for week in data.get("contributions", []): if isinstance(week, list): raw_days.extend([day for day in week if isinstance(day, dict)]) today = datetime.utcnow().date() weeks_count = 53 days_back = weeks_count * 7 + 7 start = today - timedelta(days=days_back) matrix = [["⬛" for _ in range(weeks_count)] for _ in range(7)] for entry in raw_days: date_str = entry.get("date") if not date_str: continue try: date = datetime.strptime(date_str, "%Y-%m-%d").date() if date < start or date > today: continue count = entry.get("contributionCount") or entry.get("count", 0) or 0 if count > 0: day_idx = (date.weekday() + 1) % 7 week_idx = (date - start).days // 7 if week_idx < weeks_count: matrix[day_idx][week_idx] = "🟩" except Exception: continue days_labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] graph = "\n".join(f"{days_labels[i]} {''.join(matrix[i])}" for i in range(7)) await call.edit( text=self.strings("contrib_header").format(username=username) + f"
{graph}
" + self.strings("contrib_footer"), reply_markup=[[{"text": self.strings("btn_back"), "callback": self._profile, "args": (username,)}]] )