Skip to content

Commit d201a32

Browse files
authored
Merge branch 'production' into codex/handle-404-errors-in-instagram-parser-s6wmrc
2 parents 13be091 + a1c30a5 commit d201a32

File tree

8 files changed

+352
-31
lines changed

8 files changed

+352
-31
lines changed

src/recommendations/meme_queue.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import logging
2+
from math import ceil
13
from typing import Any, Optional
24

35
from src import redis
@@ -9,6 +11,11 @@
911
get_selected_sources,
1012
)
1113
from src.storage.schemas import MemeData
14+
from sqlalchemy import text
15+
16+
from src.database import fetch_all
17+
from src.recommendations.utils import exclude_meme_ids_sql_filter
18+
from src.tgbot.constants import UserType
1219
from src.tgbot.user_info import get_user_info
1320

1421

@@ -84,8 +91,8 @@ async def generate_recommendations(
8491
Will be refactored
8592
"""
8693

94+
user_info = await get_user_info(user_id)
8795
if nmemes_sent is None:
88-
user_info = await get_user_info(user_id)
8996
nmemes_sent = user_info["nmemes_sent"]
9097

9198
queue_key = redis.get_meme_queue_key(user_id)
@@ -97,6 +104,38 @@ async def generate_recommendations(
97104
if retriever is None:
98105
retriever = CandidatesRetriever()
99106

107+
async def get_low_sent_candidates(
108+
user_id: int, limit: int, exclude_ids: list[int]
109+
) -> list[dict[str, Any]]:
110+
if limit <= 0:
111+
return []
112+
113+
query = f"""
114+
SELECT
115+
M.id,
116+
M.type,
117+
M.telegram_file_id,
118+
M.caption,
119+
'low_sent_pool' AS recommended_by
120+
FROM meme M
121+
LEFT JOIN meme_stats MS
122+
ON MS.meme_id = M.id
123+
LEFT JOIN user_meme_reaction R
124+
ON R.user_id = {user_id}
125+
AND R.meme_id = M.id
126+
INNER JOIN user_language UL
127+
ON UL.user_id = {user_id}
128+
AND UL.language_code = M.language_code
129+
WHERE 1=1
130+
AND M.status = 'ok'
131+
AND R.meme_id IS NULL
132+
{exclude_meme_ids_sql_filter(exclude_ids)}
133+
ORDER BY COALESCE(MS.nmemes_sent, 0), M.id
134+
LIMIT {limit}
135+
"""
136+
137+
return await fetch_all(text(query))
138+
100139
async def get_candidates(user_id, limit):
101140
"""A helper function to avoid copy-paste"""
102141

@@ -158,7 +197,37 @@ async def get_candidates(user_id, limit):
158197

159198
return candidates
160199

161-
candidates = await get_candidates(user_id, limit)
200+
user_type_value = user_info.get("type")
201+
user_type = None
202+
if user_type_value:
203+
try:
204+
user_type = UserType(str(user_type_value))
205+
except ValueError:
206+
logging.warning(
207+
"Unknown user type '%s' for user_id=%s during queue generation",
208+
user_type_value,
209+
user_id,
210+
)
211+
212+
candidates: list[dict[str, Any]] = []
213+
214+
if user_type in (UserType.MODERATOR, UserType.ADMIN):
215+
low_sent_quota = ceil(limit * 0.75)
216+
low_sent_candidates = await get_low_sent_candidates(
217+
user_id,
218+
low_sent_quota,
219+
meme_ids_in_queue,
220+
)
221+
candidates.extend(low_sent_candidates)
222+
meme_ids_in_queue.extend(candidate["id"] for candidate in low_sent_candidates)
223+
224+
remaining_limit = max(0, limit - len(candidates))
225+
if remaining_limit > 0:
226+
extra_candidates = await get_candidates(user_id, remaining_limit)
227+
candidates.extend(extra_candidates)
228+
else:
229+
candidates = await get_candidates(user_id, limit)
230+
162231
if len(candidates) > 0:
163232
await redis.add_memes_to_queue_by_key(queue_key, candidates)
164233

src/redis.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,4 @@ async def get_user_wrapped(user_id: str) -> dict | None:
100100

101101
async def set_user_wrapped(user_id: str, data: dict) -> None:
102102
await redis_client.set(get_user_wrapped_key(user_id), orjson.dumps(data))
103+

src/storage/parsers/ig.py

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,31 @@ async def _fetch_hikerapi( # pragma: no cover - thin wrapper around httpx
3737
return response.json()
3838

3939

40-
async def _get_user_info(username: str) -> dict | None:
41-
return await _fetch_hikerapi(
42-
"/user/by/username",
43-
params={"username": username},
44-
not_found_message=f"Instagram user '{username}' not found. Skipping.",
45-
)
40+
async def _get_user_medias(
41+
user_id: int,
42+
) -> dict | None:
43+
async with httpx.AsyncClient(timeout=20.0) as client:
44+
try:
45+
response = await client.get(
46+
"https://api.hikerapi.com/v2/user/medias",
47+
params={"user_id": user_id},
48+
headers={
49+
"accept": "application/json",
50+
"x-access-key": settings.HIKERAPI_TOKEN,
51+
},
52+
)
53+
response.raise_for_status()
54+
except httpx.HTTPStatusError as exc:
55+
if exc.response.status_code == 404:
56+
logging.warning(
57+
"Instagram user with id '%s' not found. Skipping.",
58+
user_id,
59+
)
60+
return None
4661

62+
raise
4763

48-
async def _get_user_medias(user_id: int) -> dict | None:
49-
return await _fetch_hikerapi(
50-
"/user/medias",
51-
params={"user_id": user_id},
52-
not_found_message=f"Instagram user with id '{user_id}' not found. Skipping.",
53-
)
64+
return response.json()
5465

5566

5667
async def get_user_info(instagram_username: str):
@@ -74,21 +85,10 @@ async def get_user_info(instagram_username: str):
7485
async def get_user_medias(user_id: int) -> list[IgPostParsingResult]:
7586
user_medias_response = await _get_user_medias(user_id)
7687
if not user_medias_response:
77-
return []
78-
79-
response_payload = user_medias_response.get("response") or {}
80-
if response_payload.get("status") != "ok":
81-
logging.warning("Failed to get %s medias: %s", user_id, user_medias_response)
82-
return []
83-
84-
medias = response_payload.get("items") or []
85-
if not isinstance(medias, list):
86-
logging.warning(
87-
"Unexpected medias payload for %s: %s",
88-
user_id,
89-
user_medias_response,
90-
)
91-
return []
88+
return None
89+
if user_medias_response["response"]["status"] != "ok":
90+
logging.warning(f"Failed to get {user_id} medias: {user_medias_response}")
91+
return None
9292

9393
logging.info("Received %s medias for %s", len(medias), user_id)
9494

src/tgbot/app.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
handle_broadcast_text_ru_trigger,
4343
)
4444
from src.tgbot.handlers.admin.forward_channel import handle_forwarded_from_tgchannelru
45+
from src.tgbot.handlers.admin.promote_moderator import handle_promote_moderator
4546
from src.tgbot.handlers.admin.user_info import (
4647
DELETE_USER_DATA_CONFIRMATION_CALLBACK,
4748
delete_user_data,
@@ -62,6 +63,10 @@
6263
send_tokens_to_reply,
6364
)
6465
from src.tgbot.handlers.moderator import get_meme, meme_source
66+
from src.tgbot.handlers.moderator.invite import (
67+
MODERATOR_INVITE_CALLBACK_DATA,
68+
handle_moderator_invite_callback,
69+
)
6570
from src.tgbot.handlers.payments.purchase import (
6671
PURCHASE_TOKEN_CALLBACK_DATA_REGEXP,
6772
handle_new_token_purchase_request_callback,
@@ -224,6 +229,13 @@ def add_handlers(application: Application) -> None:
224229
)
225230
)
226231

232+
application.add_handler(
233+
CallbackQueryHandler(
234+
handle_moderator_invite_callback,
235+
pattern=fr"^{MODERATOR_INVITE_CALLBACK_DATA}$",
236+
)
237+
)
238+
227239
############## popup reaction
228240
application.add_handler(
229241
CallbackQueryHandler(
@@ -241,6 +253,14 @@ def add_handlers(application: Application) -> None:
241253
)
242254
)
243255

256+
application.add_handler(
257+
CommandHandler(
258+
"promotemod",
259+
handle_promote_moderator,
260+
filters=filters.ChatType.PRIVATE & filters.UpdateType.MESSAGE,
261+
)
262+
)
263+
244264
######################
245265
# broadcast texts
246266
application.add_handlers(
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from collections.abc import Mapping
2+
3+
import logging
4+
5+
from telegram import Update
6+
from telegram.error import TelegramError
7+
from telegram.ext import ContextTypes
8+
9+
from src.tgbot.constants import TELEGRAM_MODERATOR_CHAT_ID, UserType
10+
from src.tgbot.handlers.admin.service import get_user_by_tg_username
11+
from src.tgbot.service import (
12+
add_user_tg_chat_membership,
13+
get_user_by_id,
14+
update_user,
15+
)
16+
from src.tgbot.user_info import update_user_info_cache
17+
18+
19+
async def _is_admin(user_info: Mapping[str, object] | None) -> bool:
20+
if not user_info:
21+
return False
22+
23+
raw_type = user_info.get("type")
24+
try:
25+
return UserType(str(raw_type)) == UserType.ADMIN
26+
except ValueError:
27+
logging.warning("Unknown user type '%s' encountered during admin check", raw_type)
28+
return False
29+
30+
31+
async def handle_promote_moderator(
32+
update: Update, context: ContextTypes.DEFAULT_TYPE
33+
) -> None:
34+
message = update.effective_message
35+
if message is None or message.text is None:
36+
return
37+
38+
requester = await get_user_by_id(message.from_user.id)
39+
if not await _is_admin(requester):
40+
await message.reply_text("🚫 Only admins can promote moderators.")
41+
return
42+
43+
parts = message.text.strip().split(maxsplit=1)
44+
if len(parts) < 2:
45+
await message.reply_text("Usage: /promotemod <telegram_id|@username>")
46+
return
47+
48+
identifier = parts[1].strip()
49+
target = None
50+
51+
if identifier.startswith("@"): # username lookup
52+
username = identifier[1:]
53+
if not username:
54+
await message.reply_text("🚫 Please provide a valid username after @.")
55+
return
56+
target = await get_user_by_tg_username(username)
57+
else:
58+
try:
59+
telegram_id = int(identifier)
60+
except ValueError:
61+
await message.reply_text(
62+
"🚫 Identifier must be a Telegram ID or @username."
63+
)
64+
return
65+
target = await get_user_by_id(telegram_id)
66+
67+
if target is None:
68+
await message.reply_text("🚫 Could not find the specified user.")
69+
return
70+
71+
target_id = int(target["id"])
72+
73+
try:
74+
invite_link = await context.bot.create_chat_invite_link(
75+
chat_id=TELEGRAM_MODERATOR_CHAT_ID,
76+
creates_join_request=False,
77+
member_limit=1,
78+
)
79+
except TelegramError:
80+
logging.exception("Failed to generate moderator invite link for user_id=%s", target_id)
81+
await message.reply_text("❌ Failed to generate invite link. Try again later.")
82+
return
83+
84+
await update_user(target_id, type=UserType.MODERATOR.value)
85+
await add_user_tg_chat_membership(target_id, TELEGRAM_MODERATOR_CHAT_ID)
86+
await update_user_info_cache(target_id)
87+
88+
try:
89+
await context.bot.send_message(
90+
chat_id=target_id,
91+
text=(
92+
"🎉 You have been promoted to moderator! "
93+
f"Here is your invite link: {invite_link.invite_link}"
94+
),
95+
disable_web_page_preview=True,
96+
)
97+
except TelegramError:
98+
logging.exception("Failed to send moderator invite to user_id=%s", target_id)
99+
await message.reply_text(
100+
"⚠️ Promotion updated, but sending the invite link failed."
101+
)
102+
return
103+
104+
await message.reply_text("✅ User promoted to moderator and invite link sent.")

0 commit comments

Comments
 (0)