feat: instant feedback button — creates Gitea issues with auto-labels
iOS: - Subtle floating feedback button (bottom-left, speech bubble icon) - Quick sheet: type → send → auto-creates Gitea issue - Shows checkmark on success, auto-dismisses - No friction — tap, type, done Gateway: - POST /api/feedback endpoint - Auto-labels: bug/feature/enhancement + ios/web + fitness/brain/reader/podcasts - Keyword detection for label assignment - Creates issue via Gitea API with user name and source Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ WORKDIR /app
|
||||
RUN pip install --no-cache-dir bcrypt
|
||||
RUN adduser --disabled-password --no-create-home appuser
|
||||
RUN mkdir -p /app/data && chown -R appuser /app/data
|
||||
COPY --chown=appuser server.py config.py database.py sessions.py proxy.py responses.py auth.py dashboard.py command.py assistant.py ./
|
||||
COPY --chown=appuser server.py config.py database.py sessions.py proxy.py responses.py auth.py dashboard.py command.py assistant.py feedback.py ./
|
||||
COPY --chown=appuser integrations/ ./integrations/
|
||||
EXPOSE 8100
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
@@ -12,6 +12,7 @@ import sqlite3
|
||||
|
||||
import bcrypt
|
||||
|
||||
from config import SESSION_COOKIE_SECURE
|
||||
from database import get_db
|
||||
from sessions import create_session, delete_session
|
||||
|
||||
@@ -58,7 +59,10 @@ def handle_logout(handler):
|
||||
delete_session(token)
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.send_header("Set-Cookie", "platform_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0")
|
||||
cookie = "platform_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
|
||||
if SESSION_COOKIE_SECURE:
|
||||
cookie += "; Secure"
|
||||
handler.send_header("Set-Cookie", cookie)
|
||||
resp = b'{"success": true}'
|
||||
handler.send_header("Content-Length", len(resp))
|
||||
handler.end_headers()
|
||||
|
||||
@@ -62,6 +62,7 @@ OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.2")
|
||||
|
||||
# ── Session config ──
|
||||
SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", 30 * 86400)) # 30 days
|
||||
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "").lower() in {"1", "true", "yes", "on"}
|
||||
DEV_AUTO_LOGIN = os.environ.get("DEV_AUTO_LOGIN", "").lower() in {"1", "true", "yes", "on"}
|
||||
DEV_AUTO_LOGIN_USERNAME = os.environ.get("DEV_AUTO_LOGIN_USERNAME", "dev")
|
||||
DEV_AUTO_LOGIN_DISPLAY_NAME = os.environ.get("DEV_AUTO_LOGIN_DISPLAY_NAME", "Dev User")
|
||||
|
||||
108
gateway/feedback.py
Normal file
108
gateway/feedback.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Platform Gateway — Feedback handler.
|
||||
Creates Gitea issues from user feedback with auto-labeling.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
GITEA_URL = "http://localhost:3300"
|
||||
GITEA_TOKEN = "7abae0245e49e130e1b6752b7fa381a57607d8c7"
|
||||
REPO = "yusiboyz/platform"
|
||||
|
||||
# Label IDs from Gitea
|
||||
LABELS = {
|
||||
"bug": 11,
|
||||
"feature": 12,
|
||||
"ios": 13,
|
||||
"web": 14,
|
||||
"fitness": 15,
|
||||
"brain": 16,
|
||||
"reader": 17,
|
||||
"podcasts": 18,
|
||||
"enhancement": 19,
|
||||
}
|
||||
|
||||
|
||||
def auto_label(text: str, source: str = "ios") -> list[int]:
|
||||
"""Detect labels from feedback text."""
|
||||
text_lower = text.lower()
|
||||
label_ids = []
|
||||
|
||||
# Source platform
|
||||
if source == "ios":
|
||||
label_ids.append(LABELS["ios"])
|
||||
elif source == "web":
|
||||
label_ids.append(LABELS["web"])
|
||||
|
||||
# Bug or feature
|
||||
bug_words = ["bug", "broken", "crash", "error", "wrong", "fix", "issue", "doesn't work", "not working", "can't"]
|
||||
feature_words = ["want", "add", "wish", "would be nice", "can we", "can you", "feature", "idea", "suggest"]
|
||||
|
||||
if any(w in text_lower for w in bug_words):
|
||||
label_ids.append(LABELS["bug"])
|
||||
elif any(w in text_lower for w in feature_words):
|
||||
label_ids.append(LABELS["feature"])
|
||||
else:
|
||||
label_ids.append(LABELS["enhancement"])
|
||||
|
||||
# App detection
|
||||
if any(w in text_lower for w in ["fitness", "calorie", "food", "meal", "macro", "protein"]):
|
||||
label_ids.append(LABELS["fitness"])
|
||||
elif any(w in text_lower for w in ["brain", "note", "save", "bookmark"]):
|
||||
label_ids.append(LABELS["brain"])
|
||||
elif any(w in text_lower for w in ["reader", "rss", "feed", "article"]):
|
||||
label_ids.append(LABELS["reader"])
|
||||
elif any(w in text_lower for w in ["podcast", "episode", "listen", "player"]):
|
||||
label_ids.append(LABELS["podcasts"])
|
||||
|
||||
return label_ids
|
||||
|
||||
|
||||
def handle_feedback(handler, body, user):
|
||||
"""Create a Gitea issue from user feedback."""
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except Exception:
|
||||
handler._send_json({"error": "Invalid JSON"}, 400)
|
||||
return
|
||||
|
||||
text = (data.get("text") or "").strip()
|
||||
source = data.get("source", "ios")
|
||||
|
||||
if not text:
|
||||
handler._send_json({"error": "Feedback text is required"}, 400)
|
||||
return
|
||||
|
||||
user_name = user.get("display_name") or user.get("username", "Unknown")
|
||||
label_ids = auto_label(text, source)
|
||||
|
||||
# Create Gitea issue
|
||||
issue_body = {
|
||||
"title": text[:80] + ("..." if len(text) > 80 else ""),
|
||||
"body": f"**From:** {user_name} ({source})\n\n{text}",
|
||||
"labels": label_ids,
|
||||
}
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{GITEA_URL}/api/v1/repos/{REPO}/issues",
|
||||
data=json.dumps(issue_body).encode(),
|
||||
headers={
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
result = json.loads(resp.read())
|
||||
|
||||
handler._send_json({
|
||||
"ok": True,
|
||||
"issue_number": result.get("number"),
|
||||
"url": result.get("html_url"),
|
||||
})
|
||||
except Exception as e:
|
||||
handler._send_json({"error": f"Failed to create issue: {e}"}, 500)
|
||||
@@ -5,7 +5,7 @@ Platform Gateway — Response helpers mixed into GatewayHandler.
|
||||
import json
|
||||
from http.cookies import SimpleCookie
|
||||
|
||||
from config import SESSION_MAX_AGE, DEV_AUTO_LOGIN
|
||||
from config import SESSION_MAX_AGE, SESSION_COOKIE_SECURE, DEV_AUTO_LOGIN
|
||||
from sessions import get_session_user, get_or_create_dev_user
|
||||
|
||||
|
||||
@@ -43,8 +43,10 @@ class ResponseMixin:
|
||||
return user
|
||||
|
||||
def _set_session_cookie(self, token):
|
||||
self.send_header("Set-Cookie",
|
||||
f"platform_session={token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={SESSION_MAX_AGE}")
|
||||
cookie = f"platform_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_MAX_AGE}"
|
||||
if SESSION_COOKIE_SECURE:
|
||||
cookie += "; Secure"
|
||||
self.send_header("Set-Cookie", cookie)
|
||||
|
||||
def _read_body(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
|
||||
@@ -25,6 +25,7 @@ from dashboard import (
|
||||
handle_set_connection, handle_pin, handle_unpin, handle_get_pinned,
|
||||
)
|
||||
from command import handle_command
|
||||
from feedback import handle_feedback
|
||||
from assistant import handle_assistant, handle_fitness_assistant, handle_brain_assistant
|
||||
from integrations.booklore import (
|
||||
handle_booklore_libraries, handle_booklore_import,
|
||||
@@ -243,6 +244,12 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
|
||||
handle_command(self, user, body)
|
||||
return
|
||||
|
||||
if path == "/api/feedback":
|
||||
user = self._require_auth()
|
||||
if user:
|
||||
handle_feedback(self, body, user)
|
||||
return
|
||||
|
||||
if path == "/api/assistant":
|
||||
user = self._require_auth()
|
||||
if user:
|
||||
|
||||
Reference in New Issue
Block a user