Compare commits
3 Commits
495113a83d
...
8c7de13f79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c7de13f79 | ||
|
|
bf614b4e5f | ||
|
|
63e7a2ba32 |
2
Jenkinsfile
vendored
2
Jenkinsfile
vendored
@@ -7,7 +7,7 @@ pipeline {
|
|||||||
ORACLE_USER = credentials('oracle-user')
|
ORACLE_USER = credentials('oracle-user')
|
||||||
ORACLE_PASSWORD = credentials('oracle-password')
|
ORACLE_PASSWORD = credentials('oracle-password')
|
||||||
TELEGRAM_BOT_TOKEN = credentials('telegram-bot-token')
|
TELEGRAM_BOT_TOKEN = credentials('telegram-bot-token')
|
||||||
GITEA_URL = 'http://localhost:3000'
|
GITEA_URL = 'https://gittea.cloud-handson.com'
|
||||||
GITEA_USER = 'joungmin'
|
GITEA_USER = 'joungmin'
|
||||||
GITEA_TOKEN = credentials('gitea-token')
|
GITEA_TOKEN = credentials('gitea-token')
|
||||||
|
|
||||||
|
|||||||
237
habit_bot.py
237
habit_bot.py
@@ -78,6 +78,22 @@ class UserData:
|
|||||||
save_json(FOOD_LOGS_FILE, self.food_logs)
|
save_json(FOOD_LOGS_FILE, self.food_logs)
|
||||||
save_json(USER_DATA_FILE, self.users)
|
save_json(USER_DATA_FILE, self.users)
|
||||||
|
|
||||||
|
def get_daily_totals(self, user_id: str, date: str = None) -> Dict:
|
||||||
|
"""Get daily nutrition totals for a user"""
|
||||||
|
if date is None:
|
||||||
|
date = datetime.datetime.now().strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
totals = {'calories': 0, 'carbs': 0, 'protein': 0, 'fat': 0}
|
||||||
|
|
||||||
|
if user_id in self.food_logs and date in self.food_logs[user_id]:
|
||||||
|
for log in self.food_logs[user_id][date]:
|
||||||
|
totals['calories'] += log.get('calories', 0)
|
||||||
|
totals['carbs'] += log.get('carbs', 0)
|
||||||
|
totals['protein'] += log.get('protein', 0)
|
||||||
|
totals['fat'] += log.get('fat', 0)
|
||||||
|
|
||||||
|
return totals
|
||||||
|
|
||||||
data = UserData()
|
data = UserData()
|
||||||
|
|
||||||
# URL Patterns
|
# URL Patterns
|
||||||
@@ -389,6 +405,220 @@ def analyze_food_text(text: str) -> Dict:
|
|||||||
|
|
||||||
return {'calories': calories, 'carbs': carbs, 'protein': protein, 'fat': fat}
|
return {'calories': calories, 'carbs': carbs, 'protein': protein, 'fat': fat}
|
||||||
|
|
||||||
|
# ============== MiniMax Vision API ==============
|
||||||
|
|
||||||
|
MINIMAX_API_URL = "https://api.minimax.chat/v1/text/chatcompletion_v2"
|
||||||
|
MINIMAX_API_KEY = os.environ.get('MINIMAX_API_KEY', '')
|
||||||
|
|
||||||
|
async def analyze_food_photo(file_path: str) -> Dict:
|
||||||
|
"""
|
||||||
|
Analyze food photo using MiniMax Vision API
|
||||||
|
|
||||||
|
Returns: Dict with calories, carbs, protein, fat estimation
|
||||||
|
"""
|
||||||
|
if not MINIMAX_API_KEY:
|
||||||
|
# Fallback to placeholder if no API key
|
||||||
|
return {
|
||||||
|
'calories': 400,
|
||||||
|
'carbs': 25,
|
||||||
|
'protein': 30,
|
||||||
|
'fat': 20,
|
||||||
|
'detected_foods': ['food (placeholder - add MiniMax API key)'],
|
||||||
|
'confidence': 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Read and encode image
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
image_b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||||
|
|
||||||
|
# Prepare vision prompt
|
||||||
|
prompt = """Analyze this food image and estimate nutrition:
|
||||||
|
1. What foods are in the image?
|
||||||
|
2. Estimate: calories, carbs (g), protein (g), fat (g)
|
||||||
|
3. Keto-friendly? (yes/no)
|
||||||
|
|
||||||
|
Return JSON format:
|
||||||
|
{
|
||||||
|
"foods": ["item1", "item2"],
|
||||||
|
"calories": number,
|
||||||
|
"carbs": number,
|
||||||
|
"protein": number,
|
||||||
|
"fat": number,
|
||||||
|
"keto_friendly": boolean,
|
||||||
|
"confidence": 0.0-1.0
|
||||||
|
}"""
|
||||||
|
|
||||||
|
# Call MiniMax API
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {MINIMAX_API_KEY}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": "MiniMax-Vision-01",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max_tokens": 500,
|
||||||
|
"temperature": 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
MINIMAX_API_URL,
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
# Parse JSON from response
|
||||||
|
content = result.get('choices', [{}])[0].get('message', {}).get('content', '{}')
|
||||||
|
|
||||||
|
# Extract JSON
|
||||||
|
import json as json_module
|
||||||
|
try:
|
||||||
|
# Try to parse the response as JSON
|
||||||
|
nutrition = json_module.loads(content)
|
||||||
|
return {
|
||||||
|
'calories': nutrition.get('calories', 400),
|
||||||
|
'carbs': nutrition.get('carbs', 25),
|
||||||
|
'protein': nutrition.get('protein', 30),
|
||||||
|
'fat': nutrition.get('fat', 20),
|
||||||
|
'detected_foods': nutrition.get('foods', ['unknown']),
|
||||||
|
'confidence': nutrition.get('confidence', 0.8),
|
||||||
|
'keto_friendly': nutrition.get('keto_friendly', True)
|
||||||
|
}
|
||||||
|
except json_module.JSONDecodeError:
|
||||||
|
# Fallback if JSON parsing fails
|
||||||
|
return {
|
||||||
|
'calories': 400,
|
||||||
|
'carbs': 25,
|
||||||
|
'protein': 30,
|
||||||
|
'fat': 20,
|
||||||
|
'detected_foods': ['analyzed via MiniMax'],
|
||||||
|
'confidence': 0.7
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
print(f"MiniMax API error: {response.status_code}")
|
||||||
|
return {
|
||||||
|
'calories': 400,
|
||||||
|
'carbs': 25,
|
||||||
|
'protein': 30,
|
||||||
|
'fat': 20,
|
||||||
|
'detected_foods': ['analysis failed - using defaults'],
|
||||||
|
'confidence': 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Photo analysis error: {e}")
|
||||||
|
return {
|
||||||
|
'calories': 400,
|
||||||
|
'carbs': 25,
|
||||||
|
'protein': 30,
|
||||||
|
'fat': 20,
|
||||||
|
'detected_foods': ['error - using defaults'],
|
||||||
|
'confidence': 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
async def food_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Handle food photo upload and analysis"""
|
||||||
|
user_id = str(update.message.from_user.id)
|
||||||
|
today = datetime.datetime.now().strftime('%Y-%m-%d')
|
||||||
|
now = datetime.datetime.now().strftime('%H:%M')
|
||||||
|
|
||||||
|
# Determine meal type
|
||||||
|
hour = datetime.datetime.now().hour
|
||||||
|
if 5 <= hour < 11:
|
||||||
|
meal_type = 'breakfast'
|
||||||
|
elif 11 <= hour < 14:
|
||||||
|
meal_type = 'lunch'
|
||||||
|
elif 14 <= hour < 17:
|
||||||
|
meal_type = 'snack'
|
||||||
|
else:
|
||||||
|
meal_type = 'dinner'
|
||||||
|
|
||||||
|
# Get photo
|
||||||
|
photo = update.message.photo[-1] if update.message.photo else None
|
||||||
|
if not photo:
|
||||||
|
await update.message.reply_text("❌ No photo found! Please send a food photo.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await update.message.reply_text("📸 Analyzing food photo...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Download photo
|
||||||
|
file = await context.bot.get_file(photo.file_id)
|
||||||
|
file_path = f"/tmp/food_{user_id}_{today}.jpg"
|
||||||
|
await file.download_to_drive(file_path)
|
||||||
|
|
||||||
|
# Analyze with MiniMax Vision API
|
||||||
|
nutrition = await analyze_food_photo(file_path)
|
||||||
|
|
||||||
|
# Log the food
|
||||||
|
if user_id not in data.food_logs:
|
||||||
|
data.food_logs[user_id] = {}
|
||||||
|
if today not in data.food_logs[user_id]:
|
||||||
|
data.food_logs[user_id][today] = []
|
||||||
|
|
||||||
|
data.food_logs[user_id][today].append({
|
||||||
|
'meal_type': meal_type,
|
||||||
|
'food_name': ', '.join(nutrition.get('detected_foods', ['food'])),
|
||||||
|
'time': now,
|
||||||
|
'calories': nutrition['calories'],
|
||||||
|
'carbs': nutrition['carbs'],
|
||||||
|
'protein': nutrition['protein'],
|
||||||
|
'fat': nutrition['fat'],
|
||||||
|
'source': 'photo',
|
||||||
|
'confidence': nutrition.get('confidence', 0.8),
|
||||||
|
'timestamp': datetime.datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
data.save()
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
emoji = "✅" if nutrition.get('keto_friendly', True) else "⚠️"
|
||||||
|
confidence_pct = int(nutrition.get('confidence', 0.8) * 100)
|
||||||
|
|
||||||
|
text = f"🍽️ **Food Analyzed**\n\n"
|
||||||
|
text += f"Detected: {', '.join(nutrition.get('detected_foods', ['food']))}\n"
|
||||||
|
text += f"Confidence: {confidence_pct}%\n\n"
|
||||||
|
text += f"📊 **Nutrition:**\n"
|
||||||
|
text += f"🔥 Calories: {nutrition['calories']}kcal\n"
|
||||||
|
text += f"🥦 Carbs: {nutrition['carbs']}g\n"
|
||||||
|
text += f"💪 Protein: {nutrition['protein']}g\n"
|
||||||
|
text += f"🥑 Fat: {nutrition['fat']}g\n\n"
|
||||||
|
text += f"{emoji} Keto-friendly: {'Yes' if nutrition.get('keto_friendly', True) else 'No'}\n"
|
||||||
|
|
||||||
|
# Keto check
|
||||||
|
if nutrition['carbs'] > 25:
|
||||||
|
text += "\n⚠️ Carbs exceed keto limit (25g)!"
|
||||||
|
|
||||||
|
# Daily total
|
||||||
|
total = data.get_daily_totals(user_id, today)
|
||||||
|
text += f"\n📈 **Today's Total:** {total['calories']}kcal"
|
||||||
|
text += f"\n💪 {2000 - total['calories']}kcal remaining"
|
||||||
|
|
||||||
|
await update.message.reply_text(text, parse_mode='Markdown')
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
import os
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await update.message.reply_text(f"❌ Error analyzing photo: {str(e)}")
|
||||||
|
|
||||||
async def food_today(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def food_today(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
"""Show today's food log"""
|
"""Show today's food log"""
|
||||||
user_id = str(update.message.from_user.id)
|
user_id = str(update.message.from_user.id)
|
||||||
@@ -602,12 +832,17 @@ def main():
|
|||||||
app.add_handler(CommandHandler('habit_streak', habit_streak))
|
app.add_handler(CommandHandler('habit_streak', habit_streak))
|
||||||
app.add_handler(CommandHandler('food', food_log))
|
app.add_handler(CommandHandler('food', food_log))
|
||||||
app.add_handler(CommandHandler('food_today', food_today))
|
app.add_handler(CommandHandler('food_today', food_today))
|
||||||
|
app.add_handler(CommandHandler('food_photo', food_photo))
|
||||||
app.add_handler(CommandHandler('morning', morning_briefing))
|
app.add_handler(CommandHandler('morning', morning_briefing))
|
||||||
app.add_handler(CommandHandler('debrief', debrief))
|
app.add_handler(CommandHandler('debrief', debrief))
|
||||||
app.add_handler(CommandHandler('status', lambda u, c: food_today(u, c))) # Alias
|
app.add_handler(CommandHandler('status', lambda u, c: food_today(u, c))) # Alias
|
||||||
|
|
||||||
|
# Photo handler (for food photos)
|
||||||
|
from telegram.ext import.filters
|
||||||
|
app.add_handler(MessageHandler(filters.PHOTO, food_photo))
|
||||||
|
|
||||||
# URL handler
|
# URL handler
|
||||||
app.add_handler(MessageHandler(None, handle_url))
|
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_url))
|
||||||
|
|
||||||
print("🔮 Starting Habit & Diet Bot...")
|
print("🔮 Starting Habit & Diet Bot...")
|
||||||
app.run_polling()
|
app.run_polling()
|
||||||
|
|||||||
129
stock_tracker.py
129
stock_tracker.py
@@ -17,6 +17,14 @@ from typing import List, Dict, Optional, Tuple
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
# Try import yfinance for real market data
|
||||||
|
try:
|
||||||
|
import yfinance as yf
|
||||||
|
YFINANCE_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
YFINANCE_AVAILABLE = False
|
||||||
|
print("⚠️ yfinance not installed. Run: pip install yfinance")
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
DATA_DIR = os.environ.get('PORTFOLIO_DATA_DIR', '/tmp/portfolio')
|
DATA_DIR = os.environ.get('PORTFOLIO_DATA_DIR', '/tmp/portfolio')
|
||||||
os.makedirs(DATA_DIR, exist_ok=True)
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
@@ -130,9 +138,33 @@ class StockTracker:
|
|||||||
# ============== Price Fetching ==============
|
# ============== Price Fetching ==============
|
||||||
|
|
||||||
def fetch_price(self, symbol: str) -> Optional[PriceData]:
|
def fetch_price(self, symbol: str) -> Optional[PriceData]:
|
||||||
"""Fetch current price for a symbol"""
|
"""Fetch current price for a symbol using yfinance"""
|
||||||
# Placeholder - would use real API
|
if YFINANCE_AVAILABLE:
|
||||||
# For demo, generate mock data
|
try:
|
||||||
|
# Add .KS for Korean stocks, normal for others
|
||||||
|
ticker = yf.Ticker(symbol)
|
||||||
|
info = ticker.info
|
||||||
|
|
||||||
|
current_price = info.get('currentPrice', info.get('regularMarketPrice', 0))
|
||||||
|
change_percent = info.get('regularMarketChangePercent', 0) * 100
|
||||||
|
high_52w = info.get('fiftyTwoWeekHigh', 0)
|
||||||
|
low_52w = info.get('fiftyTwoWeekLow', 0)
|
||||||
|
volume = info.get('volume', 0)
|
||||||
|
|
||||||
|
return PriceData(
|
||||||
|
symbol=symbol,
|
||||||
|
current_price=current_price,
|
||||||
|
change_percent=change_percent,
|
||||||
|
high_52w=high_52w,
|
||||||
|
low_52w=low_52w,
|
||||||
|
volume=volume,
|
||||||
|
updated_at=datetime.datetime.now().isoformat()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching {symbol}: {e}")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# Fallback to mock data if yfinance not available
|
||||||
import random
|
import random
|
||||||
mock_price = random.uniform(10000, 500000)
|
mock_price = random.uniform(10000, 500000)
|
||||||
mock_change = random.uniform(-5, 5)
|
mock_change = random.uniform(-5, 5)
|
||||||
@@ -259,6 +291,74 @@ class StockTracker:
|
|||||||
return ((price.current_price - pos.avg_cost) / pos.avg_cost) * 100
|
return ((price.current_price - pos.avg_cost) / pos.avg_cost) * 100
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
# ============== Crypto & Market Data ==============
|
||||||
|
|
||||||
|
def get_crypto_price(self, symbol: str = "BTC") -> Optional[PriceData]:
|
||||||
|
"""Fetch cryptocurrency price using yfinance"""
|
||||||
|
if not YFINANCE_AVAILABLE:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
ticker = yf.Ticker(f"{symbol}-USD")
|
||||||
|
hist = ticker.history(period='1d')
|
||||||
|
hist_1d = ticker.history(period='2d') # Get 2 days for change
|
||||||
|
|
||||||
|
if hist.empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_price = hist['Close'].iloc[-1]
|
||||||
|
prev_close = hist_1d['Close'].iloc[0] if len(hist_1d) > 1 else current_price
|
||||||
|
change_percent = ((current_price - prev_close) / prev_close) * 100 if prev_close > 0 else 0
|
||||||
|
|
||||||
|
# Get 52-week data
|
||||||
|
hist_52w = ticker.history(period='1y')
|
||||||
|
high_52w = hist_52w['High'].max() if not hist_52w.empty else current_price * 1.2
|
||||||
|
low_52w = hist_52w['Low'].min() if not hist_52w.empty else current_price * 0.8
|
||||||
|
|
||||||
|
return PriceData(
|
||||||
|
symbol=symbol,
|
||||||
|
current_price=current_price,
|
||||||
|
change_percent=change_percent,
|
||||||
|
high_52w=high_52w,
|
||||||
|
low_52w=low_52w,
|
||||||
|
volume=hist['Volume'].iloc[-1] if 'Volume' in hist.columns else 0,
|
||||||
|
updated_at=datetime.datetime.now().isoformat()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching crypto {symbol}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_market_indices(self) -> Dict[str, Dict]:
|
||||||
|
"""Fetch major market indices using yfinance"""
|
||||||
|
indices = {
|
||||||
|
'KOSPI': '^KS11',
|
||||||
|
'KOSDAQ': '^KOSDAQ',
|
||||||
|
'S&P 500': '^GSPC',
|
||||||
|
'NASDAQ': '^IXIC',
|
||||||
|
'DOW': '^DJI'
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
if YFINANCE_AVAILABLE:
|
||||||
|
for name, ticker in indices.items():
|
||||||
|
try:
|
||||||
|
t = yf.Ticker(ticker)
|
||||||
|
hist = t.history(period='1d')
|
||||||
|
hist_1d = t.history(period='2d') # Get 2 days for change calculation
|
||||||
|
|
||||||
|
if not hist.empty:
|
||||||
|
current = hist['Close'].iloc[-1]
|
||||||
|
prev_close = hist_1d['Close'].iloc[0] if len(hist_1d) > 1 else current
|
||||||
|
change = ((current - prev_close) / prev_close) * 100 if prev_close > 0 else 0
|
||||||
|
result[name] = {'price': current, 'change': change}
|
||||||
|
else:
|
||||||
|
result[name] = {'price': 0, 'change': 0}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching {name}: {e}")
|
||||||
|
result[name] = {'price': 0, 'change': 0}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
# ============== Reporting ==============
|
# ============== Reporting ==============
|
||||||
|
|
||||||
def generate_daily_report(self) -> str:
|
def generate_daily_report(self) -> str:
|
||||||
@@ -346,6 +446,13 @@ def main():
|
|||||||
# Weekly report
|
# Weekly report
|
||||||
subparsers.add_parser('weekly', help='Generate weekly report')
|
subparsers.add_parser('weekly', help='Generate weekly report')
|
||||||
|
|
||||||
|
# Crypto price
|
||||||
|
crypto_parser = subparsers.add_parser('crypto', help='Get crypto price')
|
||||||
|
crypto_parser.add_argument('--symbol', default='BTC', help='Crypto symbol (BTC, ETH, etc.)')
|
||||||
|
|
||||||
|
# Market indices
|
||||||
|
subparsers.add_parser('market', help='Show market indices')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
tracker = StockTracker()
|
tracker = StockTracker()
|
||||||
@@ -374,6 +481,22 @@ def main():
|
|||||||
elif args.command == 'weekly':
|
elif args.command == 'weekly':
|
||||||
print(tracker.generate_weekly_report())
|
print(tracker.generate_weekly_report())
|
||||||
|
|
||||||
|
elif args.command == 'crypto':
|
||||||
|
price = tracker.get_crypto_price(args.symbol)
|
||||||
|
if price:
|
||||||
|
emoji = "🟢" if price.change_percent > 0 else "🔴"
|
||||||
|
print(f"\n{emoji} {args.symbol}: ${price.current_price:,.2f} ({price.change_percent:+.2f}%)")
|
||||||
|
print(f" 52W Range: ${price.low_52w:,.2f} - ${price.high_52w:,.2f}")
|
||||||
|
else:
|
||||||
|
print("❌ yfinance not available. Install: pip install yfinance")
|
||||||
|
|
||||||
|
elif args.command == 'market':
|
||||||
|
indices = tracker.get_market_indices()
|
||||||
|
print("\n📈 Market Indices")
|
||||||
|
for name, data in indices.items():
|
||||||
|
emoji = "🟢" if data['change'] > 0 else "🔴"
|
||||||
|
print(f" {emoji} {name}: {data['price']:,.2f} ({data['change']:+.2f}%)")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user