Skip to content

Instantly share code, notes, and snippets.

@levelsio
Last active January 24, 2026 05:59
Show Gist options
  • Select an option

  • Save levelsio/b4467fd2fb63bc5373fd3e8559d5ad62 to your computer and use it in GitHub Desktop.

Select an option

Save levelsio/b4467fd2fb63bc5373fd3e8559d5ad62 to your computer and use it in GitHub Desktop.
πŸ’β€β™€οΈ Karen Bot is an AI robot that lets you report problems in your neighborhood or city to your local city council
<?php
// Karen Bot by @levelsio
//
// ask Claude Code or Cursor to adapt it to your city
// mine is for Lisbon in Portuguese but it should work with any city and any language!
// save it as council.php and add a Nginx route /council on your server to use it
// make sure to add /council?key= and set a key below to use it privately
//
// more info: https://x.com/levelsio/status/2009011216132526407?s=20
// <set vars here>
// this is the key you add to the url like /council?key= so only you can access it
define('KEY_TO_ACCESS_THE_SCRIPT', 'key');
// I used Postmark but you can chance to other email services of coruse
define('POSTMARK_API_KEY', 'INSERT_KEY_HERE');
define('OPENAI_API_KEY', 'your-key-here');
// email addresses
define('YOUR_NAME', 'Your Name To Sign The Letter');
define('FROM_YOUR_EMAIL', 'you@you.com');
define('TO_COUNCIL_EMAIL', 'council@city.com');
define('CC_EMAILS', 'gf@gf.com');
// set to your city
define('MAP_CENTER_LAT', 38.734674);
define('MAP_CENTER_LNG', -9.16427);
// </set vars here>
error_reporting(0);
ini_set('upload_max_filesize', '50M');
ini_set('post_max_size', '50M');
ini_set('max_file_uploads', '20');
// Simple access key check
if (($_GET['key'] ?? '') !== KEY_TO_ACCESS_THE_SCRIPT) {
http_response_code(404);
exit('Not found');
}
$config = [
'postmark' => [
'token' => POSTMARK_API_KEY,
'from' => FROM_YOUR_EMAIL,
'to' => TO_COUNCIL_EMAIL
],
'openai' => [
'key' => OPENAI_API_KEY
]
];
$message = '';
$messageType = '';
// Handle AJAX request for GPT expansion
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'expand') {
header('Content-Type: application/json');
$complaint = trim($_POST['complaint'] ?? '');
$hasAttachments = isset($_POST['hasAttachments']) && $_POST['hasAttachments'] === 'true';
$address = trim($_POST['address'] ?? '');
$lat = trim($_POST['lat'] ?? '');
$lng = trim($_POST['lng'] ?? '');
if (empty($complaint)) {
echo json_encode(['success' => false, 'error' => 'Complaint text is required']);
exit;
}
$systemPrompt = "VocΓͺ Γ© um assistente que transforma reclamaΓ§Γ΅es informais em cartas formais em portuguΓͺs de Portugal para enviar Γ  CΓ’mara Municipal.
FORMATO DA CARTA:
ComeΓ§a SEMPRE com um bloco de dados estruturados para scan rΓ‘pido (sΓ³ inclui campos que tens informaΓ§Γ£o):
---
Resumo: [uma linha resumindo o problema, ex: 'AcumulaΓ§Γ£o de lixo junto ao contentor hΓ‘ 3 dias']
LocalizaΓ§Γ£o: [morada/rua mencionada]
Google Maps: [link se fornecido]
---
Depois escreve a carta formal:
- SaudaΓ§Γ£o formal gender-neutral: usa 'Ex.mos Senhores,' (dirigido Γ  CΓ’mara em geral, nΓ£o ao presidente)
- Estruturar o problema de forma clara
- Incluir um pedido de aΓ§Γ£o especΓ­fico
- Terminar com despedida formal
IMPORTANTE: NUNCA adicionar placeholders, templates, ou texto entre parΓͺnteses retos como [nome], [morada], [EspaΓ§o para...], etc. A carta deve estar pronta a enviar sem qualquer texto para preencher. Se nΓ£o tens uma informaΓ§Γ£o, simplesmente nΓ£o incluas esse campo.
Assina sempre a carta com:
Com os melhores cumprimentos,
".YOUR_NAME;
$userPrompt = "Transforma a seguinte reclamaΓ§Γ£o numa carta formal para a CΓ’mara Municipal de Lisboa.\n\n";
if (!empty($address)) {
$userPrompt .= "LocalizaΓ§Γ£o do problema: $address\n";
if (!empty($lat) && !empty($lng)) {
$userPrompt .= "Google Maps: https://www.google.com/maps/@$lat,$lng,100m/data=!3m1!1e3\n";
}
$userPrompt .= "\n";
}
$userPrompt .= "Report:\n$complaint";
if ($hasAttachments) {
$userPrompt .= "\n\nNOTA: O remetente vai anexar fotografias como prova. Menciona isso na carta (ex: 'Em anexo envio fotografias que comprovam a situaΓ§Γ£o descrita.')";
}
$userPrompt .= "\n\nEscreve primeiro a carta em portuguΓͺs, depois escreve ===ENGLISH=== e a traduΓ§Γ£o em inglΓͺs.";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . OPENAI_API_KEY
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'model' => 'gpt-4o-mini',
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt]
],
'max_tokens' => 2000,
'temperature' => 0.7
]));
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
echo json_encode(['success' => false, 'error' => 'Failed to connect to OpenAI API']);
exit;
}
$data = json_decode($response, true);
if (isset($data['choices'][0]['message']['content'])) {
echo json_encode(['success' => true, 'expanded' => $data['choices'][0]['message']['content']]);
} else {
echo json_encode(['success' => false, 'error' => 'Invalid response from OpenAI']);
}
exit;
}
// Handle form submission (send email)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'send') {
header('Content-Type: application/json');
$complaint = trim($_POST['complaint'] ?? '');
$lat = trim($_POST['lat'] ?? '');
$lng = trim($_POST['lng'] ?? '');
// Extract Resumo from the letter for subject, or fallback to first 60 chars
if (preg_match('/Resumo:\s*(.+)/i', $complaint, $matches)) {
$subject = trim($matches[1]);
} else {
$subject = mb_substr(preg_replace('/\s+/', ' ', $complaint), 0, 60);
if (mb_strlen($complaint) > 60) $subject .= '...';
}
if (empty($complaint)) {
echo json_encode(['success' => false, 'message' => 'Please fill in the complaint.']);
exit;
}
// Build email body
$emailBody = $complaint;
$emailBody .= "\n\nSent from my iPhone";
// Prepare attachments (resize images to ~1MB max)
$attachments = [];
if (isset($_FILES['attachments']) && is_array($_FILES['attachments']['name'])) {
for ($i = 0; $i < count($_FILES['attachments']['name']); $i++) {
if ($_FILES['attachments']['error'][$i] === UPLOAD_ERR_OK) {
$tmpName = $_FILES['attachments']['tmp_name'][$i];
$fileName = $_FILES['attachments']['name'][$i];
$mimeType = mime_content_type($tmpName);
// Resize image if it's too large
$maxSize = 1024 * 1024; // 1MB
$fileSize = filesize($tmpName);
if (strpos($mimeType, 'image/') === 0 && $fileSize > $maxSize) {
$imageData = resizeImage($tmpName, $mimeType, $maxSize);
$content = base64_encode($imageData);
// Update filename to jpg if converted
if ($mimeType !== 'image/jpeg') {
$fileName = pathinfo($fileName, PATHINFO_FILENAME) . '.jpg';
$mimeType = 'image/jpeg';
}
} else {
$content = base64_encode(file_get_contents($tmpName));
}
$attachments[] = [
'Name' => $fileName,
'Content' => $content,
'ContentType' => $mimeType
];
}
}
}
function resizeImage($filePath, $mimeType, $maxSize) {
// Load image
switch ($mimeType) {
case 'image/jpeg':
$img = imagecreatefromjpeg($filePath);
break;
case 'image/png':
$img = imagecreatefrompng($filePath);
break;
case 'image/gif':
$img = imagecreatefromgif($filePath);
break;
case 'image/webp':
$img = imagecreatefromwebp($filePath);
break;
default:
return file_get_contents($filePath);
}
if (!$img) return file_get_contents($filePath);
// Get dimensions
$width = imagesx($img);
$height = imagesy($img);
// Start with quality 85 and reduce until under maxSize
$quality = 85;
do {
ob_start();
imagejpeg($img, null, $quality);
$data = ob_get_clean();
$quality -= 10;
} while (strlen($data) > $maxSize && $quality > 20);
// If still too large, also reduce dimensions
if (strlen($data) > $maxSize) {
$scale = sqrt($maxSize / strlen($data));
$newWidth = (int)($width * $scale);
$newHeight = (int)($height * $scale);
$resized = imagecreatetruecolor($newWidth, $newHeight);
imagecopyresampled($resized, $img, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
imagedestroy($img);
$img = $resized;
ob_start();
imagejpeg($img, null, 70);
$data = ob_get_clean();
}
imagedestroy($img);
return $data;
}
// Check if files were uploaded but none processed (attachment failure)
$filesUploaded = isset($_FILES['attachments']) && !empty($_FILES['attachments']['name'][0]);
if ($filesUploaded && empty($attachments)) {
echo json_encode(['success' => false, 'message' => 'Failed to process attachments. Please try again.']);
exit;
}
// Send via Postmark
$emailPayload = [
'From' => $config['postmark']['from'],
'To' => $config['postmark']['to'],
'Cc' => CC_EMAILS,
'Subject' => $subject,
'TextBody' => $emailBody,
'MessageStream' => 'outbound'
];
if (!empty($attachments)) {
$emailPayload['Attachments'] = $attachments;
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://api.postmarkapp.com/email');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
'Content-Type: application/json',
'X-Postmark-Server-Token: ' . $config['postmark']['token']
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($emailPayload));
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$responseData = json_decode($response, true);
if ($httpCode === 200 && isset($responseData['MessageID'])) {
$attachmentCount = count($attachments);
echo json_encode(['success' => true, 'message' => "Complaint sent successfully! ({$attachmentCount} photo" . ($attachmentCount != 1 ? 's' : '') . " attached)"]);
} else {
$errorMsg = $responseData['Message'] ?? 'Unknown error';
echo json_encode(['success' => false, 'message' => 'Error sending: ' . $errorMsg]);
}
exit;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Report to Lisbon City Council (πŸ’β€β™€οΈ Karen Bot)</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>πŸ‡΅πŸ‡Ή</text></svg>">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 700px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
color: #333;
}
h1 {
color: #000000;
border-bottom: 3px solid #000000;
padding-bottom: 10px;
}
.info {
background: #e8f5e9;
border-left: 4px solid #000000;
padding: 15px;
margin-bottom: 20px;
}
form {
background: white;
padding: 25px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
label {
display: block;
margin-bottom: 5px;
font-weight: 600;
}
input[type="text"], textarea {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
margin-bottom: 15px;
}
textarea {
min-height: 200px;
resize: vertical;
font-family: inherit;
}
textarea[readonly] {
background: #f9f9f9;
color: #555;
}
#map {
height: 250px;
width: 100%;
border-radius: 4px;
margin-bottom: 10px;
border: 1px solid #ddd;
}
.location-info {
font-size: 16px;
color: #333;
margin-bottom: 15px;
}
input[type="file"] {
margin-bottom: 15px;
}
.buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 12px 24px;
font-size: 16px;
font-weight: bold;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
button[type="submit"] {
background: #000000;
color: white;
}
button[type="submit"]:hover {
background: #333333;
}
button[type="button"] {
background: #2196F3;
color: white;
}
button[type="button"]:hover {
background: #1976D2;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.message {
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #fff;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.recipient {
color: #666;
font-size: 14px;
margin-top: -10px;
margin-bottom: 20px;
}
.upload-box {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 0;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
margin-bottom: 10px;
}
.upload-box:hover {
border-color: #2196F3;
background: #f8f9fa;
}
.upload-box.dragover {
border-color: #2196F3;
background: #e3f2fd;
}
.upload-box svg {
width: 48px;
height: 48px;
fill: #999;
margin-bottom: 10px;
}
.upload-box:hover svg {
fill: #2196F3;
}
.upload-box p {
margin: 0;
color: #666;
font-size: 14px;
}
.upload-box input[type="file"] {
display: none;
}
</style>
</head>
<body>
<h1>πŸ‡΅πŸ‡Ή Report to Lisbon City Council (πŸ’β€β™€οΈ Karen Bot)</h1>
<?php if ($message): ?>
<div class="message <?= $messageType ?>"><?= htmlspecialchars($message) ?></div>
<?php endif; ?>
<form method="POST" enctype="multipart/form-data" id="complaintForm">
<input type="hidden" name="action" value="send">
<input type="hidden" id="selectedLat" name="lat" value="">
<input type="hidden" id="selectedLng" name="lng" value="">
<button type="button" onclick="useMyLocation()" style="margin-bottom: 10px;">Center on my location</button>
<label>Then click on map to place marker</label>
<div id="map"></div>
<div class="location-info" id="locationInfo">No location selected</div>
<label>Attach images (optional)</label>
<div class="upload-box" id="uploadBox" onclick="document.getElementById('attachments').click()">
<div id="uploadPlaceholder">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
<p>Click to upload photos or drag & drop</p>
</div>
<div id="imagePreview" style="display: flex; gap: 10px; flex-wrap: wrap; justify-content: center;"></div>
<input type="file" id="attachments" name="attachments[]" multiple accept="image/*" onchange="previewImages(this)">
</div>
<label for="input">Your complaint</label>
<textarea id="input" required placeholder="Write your complaint here..." oninput="localStorage.setItem('complaint', this.value)" style="min-height: 100px;"></textarea>
<button type="button" id="expandBtn" onclick="expandToFormalLetter()">
Write letter
</button>
<label for="complaint" style="margin-top: 15px;">Portuguese formal letter (this will be sent)</label>
<textarea id="complaint" name="complaint" placeholder="Portuguese letter will appear here..." style="min-height: 400px;"><?= htmlspecialchars($complaint ?? '') ?></textarea>
<label for="expanded" style="margin-top: 15px;">English translation (for your reference)</label>
<textarea id="expanded" readonly placeholder="English translation will appear here..." style="min-height: 400px;"><?= htmlspecialchars($expanded ?? '') ?></textarea>
<div class="buttons">
<button type="button" onclick="sendComplaint()">Send complaint</button>
</div>
<div id="statusMessage" style="margin-top: 15px;"></div>
</form>
<script>
// Initialize map centered on New York City (default)
const map = L.map('map').setView([MAP_CENTER_LAT, MAP_CENTER_LNG], 13);
const streets = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: 'Β© OpenStreetMap'
});
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Β© Esri'
});
satellite.addTo(map); // Default to satellite
L.control.layers({ 'Streets': streets, 'Satellite': satellite }).addTo(map);
function useMyLocation() {
if (!navigator.geolocation) {
alert('Geolocation not supported');
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
map.setView([pos.coords.latitude, pos.coords.longitude], 16);
},
(err) => {
alert('Could not get location. Please allow location access.');
},
{ enableHighAccuracy: true }
);
}
let marker = null;
let selectedAddress = '';
// User must click "Use my location" button to center map
map.on('click', async function(e) {
const lat = e.latlng.lat;
const lng = e.latlng.lng;
// Update hidden fields
document.getElementById('selectedLat').value = lat;
document.getElementById('selectedLng').value = lng;
// Place/move marker
if (marker) {
marker.setLatLng(e.latlng);
} else {
marker = L.marker(e.latlng).addTo(map);
}
// Show loading
document.getElementById('locationInfo').textContent = 'Getting address...';
// Reverse geocode
try {
const resp = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`, {
headers: { 'User-Agent': 'LisbonComplaints/1.0' }
});
const data = await resp.json();
selectedAddress = data.display_name || `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
document.getElementById('locationInfo').textContent = 'πŸ“ ' + selectedAddress;
} catch (err) {
selectedAddress = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
document.getElementById('locationInfo').textContent = 'πŸ“ ' + selectedAddress;
}
});
// Load saved complaint from localStorage
document.getElementById('input').value = localStorage.getItem('complaint') || '';
// Drag and drop support
const uploadBox = document.getElementById('uploadBox');
const fileInput = document.getElementById('attachments');
uploadBox.addEventListener('dragover', (e) => {
e.preventDefault();
uploadBox.classList.add('dragover');
});
uploadBox.addEventListener('dragleave', (e) => {
e.preventDefault();
uploadBox.classList.remove('dragover');
});
uploadBox.addEventListener('drop', (e) => {
e.preventDefault();
uploadBox.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
fileInput.files = e.dataTransfer.files;
previewImages(fileInput);
}
});
function previewImages(input) {
const preview = document.getElementById('imagePreview');
const placeholder = document.getElementById('uploadPlaceholder');
preview.innerHTML = '';
if (input.files && input.files.length > 0) {
placeholder.style.display = 'none';
Array.from(input.files).forEach(file => {
const reader = new FileReader();
reader.onload = (e) => {
const img = document.createElement('img');
img.src = e.target.result;
img.style.cssText = 'width: 100%; border-radius: 4px; border: 1px solid #ddd;';
preview.appendChild(img);
};
reader.readAsDataURL(file);
});
} else {
placeholder.style.display = 'block';
}
}
async function expandToFormalLetter() {
const input = document.getElementById('input').value;
const attachments = document.getElementById('attachments');
const hasAttachments = attachments.files && attachments.files.length > 0;
const btn = document.getElementById('expandBtn');
const lat = document.getElementById('selectedLat').value;
const lng = document.getElementById('selectedLng').value;
if (!lat || !lng) {
alert('Please click on the map to set the problem location first.');
return;
}
if (!input.trim()) {
alert('Please write your complaint first.');
return;
}
btn.disabled = true;
btn.innerHTML = 'Writing...<span class="loading"></span>';
try {
const formData = new FormData();
formData.append('action', 'expand');
formData.append('complaint', input);
formData.append('hasAttachments', hasAttachments);
formData.append('address', selectedAddress);
formData.append('lat', document.getElementById('selectedLat').value);
formData.append('lng', document.getElementById('selectedLng').value);
const response = await fetch(window.location.href, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
const parts = data.expanded.split('===ENGLISH===');
document.getElementById('complaint').value = parts[0].trim();
document.getElementById('expanded').value = parts[1] ? parts[1].trim() : '';
} else {
alert('Error: ' + (data.error || 'Failed to expand'));
}
} catch (error) {
alert('Connection error. Please try again.');
console.error(error);
} finally {
btn.disabled = false;
btn.innerHTML = 'Write letter';
}
}
async function sendComplaint() {
const complaint = document.getElementById('complaint').value;
const statusDiv = document.getElementById('statusMessage');
const attachments = document.getElementById('attachments');
const hasAttachments = attachments.files && attachments.files.length > 0;
if (!complaint.trim()) {
alert('Please expand your complaint to a formal letter first.');
return;
}
if (!hasAttachments) {
if (!confirm('No photo attached. Send anyway?')) {
return;
}
}
if (!confirm('Are you sure you want to send this complaint?')) {
return;
}
const formData = new FormData(document.getElementById('complaintForm'));
formData.set('action', 'send');
statusDiv.innerHTML = '<span style="color: #666;">Sending...</span>';
try {
const response = await fetch(window.location.pathname + window.location.search, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
statusDiv.innerHTML = '<span style="color: green;">' + data.message + '</span>';
document.getElementById('input').value = '';
document.getElementById('complaint').value = '';
document.getElementById('expanded').value = '';
localStorage.removeItem('complaint');
} else {
statusDiv.innerHTML = '<span style="color: red;">' + data.message + '</span>';
}
} catch (error) {
statusDiv.innerHTML = '<span style="color: red;">Connection error. Please try again.</span>';
console.error(error);
}
}
</script>
</body>
</html>
@squashmaster
Copy link

Thanks for sharing Pieter

@Maxvyr
Copy link

Maxvyr commented Jan 9, 2026

Thanks πŸ™

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment