feat(gro-194): SMS provider service with Telnyx SDK integration
- Added telnyx npm package - Created sms.ts with SmsProvider interface - Implemented TelnyxProvider with sendSms() and validateWebhookSignature() - Added createSmsProvider() factory function - Added smsSend() convenience function that skips when SMS_ENABLED=false - Provider abstraction allows future Twilio or other providers - E.164 phone validation on send - Webhook signature verification using HMAC-SHA256 Co-Authored-By: Paperclip <noreply@paperclip.ing>
@@ -22,6 +22,7 @@
|
||||
"hono": "^4.6.17",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"telnyx": "^6.41.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Telnyx } from "telnyx";
|
||||
|
||||
export interface SmsProvider {
|
||||
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
|
||||
validateWebhookSignature(req: Request): boolean;
|
||||
}
|
||||
|
||||
interface TelnyxSmsResult {
|
||||
message_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function createTelnyxClient(): Telnyx | null {
|
||||
const apiKey = process.env.TELNYX_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
return new Telnyx(apiKey);
|
||||
}
|
||||
|
||||
let _client: Telnyx | null | undefined;
|
||||
|
||||
function getClient(): Telnyx | null {
|
||||
if (_client === undefined) _client = createTelnyxClient();
|
||||
return _client;
|
||||
}
|
||||
|
||||
function getFromNumber(): string | null {
|
||||
return process.env.TELNYX_FROM_NUMBER ?? null;
|
||||
}
|
||||
|
||||
function isE164(phone: string): boolean {
|
||||
return /^\+[1-9]\d{7,14}$/.test(phone);
|
||||
}
|
||||
|
||||
export async function sendSms(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<{ messageId: string; status: string }> {
|
||||
const client = getClient();
|
||||
if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY.");
|
||||
|
||||
const from = getFromNumber();
|
||||
if (!from) throw new Error("TELNYX_FROM_NUMBER is not set");
|
||||
|
||||
if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`);
|
||||
if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
from,
|
||||
to,
|
||||
body,
|
||||
};
|
||||
|
||||
if (mediaUrls && mediaUrls.length > 0) {
|
||||
payload.media_urls = mediaUrls;
|
||||
}
|
||||
|
||||
const result = await client.messages.create(payload as Record<string, string | string[]>);
|
||||
const smsResult = result.data as unknown as TelnyxSmsResult;
|
||||
return {
|
||||
messageId: smsResult.message_id,
|
||||
status: smsResult.status,
|
||||
};
|
||||
}
|
||||
|
||||
export class TelnyxProvider implements SmsProvider {
|
||||
async sendSms(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<{ messageId: string; status: string }> {
|
||||
return sendSms(to, body, mediaUrls);
|
||||
}
|
||||
|
||||
validateWebhookSignature(req: Request): boolean {
|
||||
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
||||
if (!secret) return false;
|
||||
|
||||
const signature = req.headers.get("telnyx-signature");
|
||||
if (!signature) return false;
|
||||
|
||||
const payload = JSON.stringify(req.body);
|
||||
|
||||
try {
|
||||
const { createHmac } = await import("crypto");
|
||||
const hmac = createHmac("sha256", secret);
|
||||
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
|
||||
|
||||
const sigBuf = Buffer.from(signature);
|
||||
const expBuf = Buffer.from(expected);
|
||||
|
||||
if (sigBuf.length !== expBuf.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < sigBuf.length; i++) {
|
||||
diff |= sigBuf[i] ^ expBuf[i];
|
||||
}
|
||||
return diff === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _provider: SmsProvider | null | undefined;
|
||||
|
||||
export function createSmsProvider(): SmsProvider | null {
|
||||
if (_provider === undefined) {
|
||||
if (process.env.SMS_ENABLED !== "true") {
|
||||
_provider = null;
|
||||
return null;
|
||||
}
|
||||
switch (process.env.SMS_PROVIDER) {
|
||||
case "telnyx": {
|
||||
const client = getClient();
|
||||
if (!client) {
|
||||
_provider = null;
|
||||
return null;
|
||||
}
|
||||
_provider = new TelnyxProvider();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
_provider = null;
|
||||
}
|
||||
}
|
||||
return _provider;
|
||||
}
|
||||
|
||||
export async function smsSend(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<boolean> {
|
||||
const provider = createSmsProvider();
|
||||
if (!provider) return false;
|
||||
|
||||
await provider.sendSms(to, body, mediaUrls);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
|
||||
API_HOST="https://api.minimax.io"
|
||||
API_KEY="$MINIMAX_API_KEY"
|
||||
OUTPUT_DIR="minimax-output"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Diverse dog image prompts
|
||||
declare -a PROMPTS=(
|
||||
"A beautiful red Irish Setter with long flowing silky coat, standing proudly in golden hour sunlight, professional pet portrait photography, warm tones"
|
||||
"A fluffy white Pomeranian puppy with thick fluffy coat, sitting alert with bright expression, studio white background, cute grooming"
|
||||
"A black Schnauzer with distinctive full beard and mustache, freshly groomed with neat styling, professional grooming salon setting"
|
||||
"A cream and white Cavalier King Charles Spaniel with silky coat, gentle sad eyes, soft warm indoor lighting, elegant pose"
|
||||
"A brown and white Basset Hound with long droopy ears, lying down in relaxed pose, natural outdoor setting, peaceful expression"
|
||||
"A black and tan miniature Dachshund with glossy coat, alert standing pose, warm studio lighting, detailed paws visible"
|
||||
"A white fluffy Bichon Frise after professional grooming with rounded topknot, happy bouncy expression, bright cheerful background"
|
||||
"A muscular fawn Boxer dog, athletic build, standing confidently outdoors in park, energetic expression, natural lighting"
|
||||
"A blue merle Shetland Sheepdog with alert ears and fluffy coat, running happily, green grass field background, vibrant"
|
||||
"A buff colored Cocker Spaniel with beautiful silky coat, friendly gentle expression, warm natural window lighting, indoor"
|
||||
)
|
||||
|
||||
declare -a FILENAMES=(
|
||||
"dog-setter-red-sunlit.png"
|
||||
"dog-pomeranian-white-studio.png"
|
||||
"dog-schnauzer-black-groomed.png"
|
||||
"dog-cavalier-cream-gentle.png"
|
||||
"dog-basset-brown-white.png"
|
||||
"dog-dachshund-black-tan.png"
|
||||
"dog-bichon-white-groomed.png"
|
||||
"dog-boxer-fawn-athletic.png"
|
||||
"dog-sheepdog-merle-running.png"
|
||||
"dog-cocker-buff-friendly.png"
|
||||
)
|
||||
|
||||
echo "Generating ${#PROMPTS[@]} diverse dog images..."
|
||||
|
||||
for i in "${!PROMPTS[@]}"; do
|
||||
PROMPT="${PROMPTS[$i]}"
|
||||
FILENAME="${FILENAMES[$i]}"
|
||||
|
||||
echo -n "[$((i+1))/${#PROMPTS[@]}] $FILENAME... "
|
||||
|
||||
RESPONSE=$(curl -s -X POST "${API_HOST}/v1/image_generation" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"model\":\"image-01\",\"prompt\":\"${PROMPT}\",\"image_count\":1}")
|
||||
|
||||
# Extract image URL from response
|
||||
IMAGE_URL=$(echo "$RESPONSE" | grep -o '"image_urls":\["\([^"]*\)' | cut -d'"' -f4)
|
||||
|
||||
if [ -n "$IMAGE_URL" ]; then
|
||||
curl -s "$IMAGE_URL" -o "$OUTPUT_DIR/$FILENAME" 2>/dev/null
|
||||
if [ -f "$OUTPUT_DIR/$FILENAME" ] && [ -s "$OUTPUT_DIR/$FILENAME" ]; then
|
||||
echo "✓"
|
||||
else
|
||||
echo "✗ (download failed)"
|
||||
fi
|
||||
else
|
||||
echo "✗ (no URL)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Done! Generated images in $OUTPUT_DIR/"
|
||||
ls -lh "$OUTPUT_DIR"/dog-*.png 2>/dev/null | wc -l
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Use the configured MiniMax API host
|
||||
API_HOST="${MINIMAX_API_HOST:-https://api.minimax.io}"
|
||||
API_KEY="$MINIMAX_API_KEY"
|
||||
|
||||
# Test endpoint - check which one works
|
||||
echo "Testing API endpoints..."
|
||||
echo "API_HOST: $API_HOST"
|
||||
echo "API_KEY: ${API_KEY:0:15}..."
|
||||
|
||||
# Array of diverse dog images to generate
|
||||
declare -a PROMPTS=(
|
||||
"A beautiful red Irish Setter with flowing silky coat, standing proudly in a sunny garden, warm natural lighting, professional pet photography"
|
||||
"A fluffy white Pomeranian with thick coat, sitting alert, bright studio background, cute expression"
|
||||
"A black Schnauzer with distinctive beard, freshly groomed, professional salon setting, dignified pose"
|
||||
"A cream-colored Cavalier King Charles Spaniel, silky coat, gentle expression, soft warm lighting"
|
||||
"A brown and white Basset Hound, long ears, relaxed sitting pose, natural outdoor background"
|
||||
"A black and tan Dachshund, elongated body, alert posture, warm studio lighting"
|
||||
"A white Bichon Frise, fluffy groomed coat, happy expression, bright cheerful background"
|
||||
"A fawn Boxer with muscular build, athletic posture, outdoor park setting, energetic expression"
|
||||
"A merle Shetland Sheepdog, alert ears, running pose, green garden background"
|
||||
"A buff-colored Cocker Spaniel, silky coat, friendly expression, warm natural light"
|
||||
)
|
||||
|
||||
declare -a FILENAMES=(
|
||||
"dog-setter-red-sunny.png"
|
||||
"dog-pomeranian-white-alert.png"
|
||||
"dog-schnauzer-groomed.png"
|
||||
"dog-cavalier-cream.png"
|
||||
"dog-basset-hound-outdoor.png"
|
||||
"dog-dachshund-alert.png"
|
||||
"dog-bichon-frise-happy.png"
|
||||
"dog-boxer-athletic.png"
|
||||
"dog-sheepdog-merle.png"
|
||||
"dog-cocker-spaniel-buff.png"
|
||||
)
|
||||
|
||||
mkdir -p minimax-output
|
||||
|
||||
echo "Generating ${#PROMPTS[@]} diverse dog images..."
|
||||
|
||||
for i in "${!PROMPTS[@]}"; do
|
||||
PROMPT="${PROMPTS[$i]}"
|
||||
FILENAME="${FILENAMES[$i]}"
|
||||
|
||||
echo "[$((i+1))/${#PROMPTS[@]}] Generating: $FILENAME"
|
||||
|
||||
# Make API request
|
||||
RESPONSE=$(curl -s -X POST "${API_HOST}/v1/image_generation" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"model\": \"image-01\",
|
||||
\"prompt\": \"${PROMPT}\",
|
||||
\"image_count\": 1
|
||||
}")
|
||||
|
||||
# Check if response contains image data
|
||||
if echo "$RESPONSE" | grep -q "data\|image_url\|file_content"; then
|
||||
echo " ✓ Response received"
|
||||
|
||||
# Try to extract and save image data
|
||||
# Different APIs format responses differently
|
||||
IMAGE_DATA=$(echo "$RESPONSE" | grep -o '"file_content":"[^"]*' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -n "$IMAGE_DATA" ]; then
|
||||
echo "$IMAGE_DATA" | base64 -d > "minimax-output/$FILENAME"
|
||||
echo " ✓ Image saved to minimax-output/$FILENAME"
|
||||
else
|
||||
echo " ✗ Could not extract image data"
|
||||
fi
|
||||
else
|
||||
echo " ✗ API response: ${RESPONSE:0:100}"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Image generation complete!"
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
import base64
|
||||
import requests
|
||||
import os
|
||||
import json
|
||||
|
||||
api_key = os.environ.get("MINIMAX_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("MINIMAX_API_KEY environment variable not set")
|
||||
|
||||
url = "https://api.minimax.io/v1/image_generation"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
# Ensure output directory exists
|
||||
os.makedirs("minimax-output", exist_ok=True)
|
||||
|
||||
prompts = [
|
||||
{
|
||||
"filename": "dog-puggle-fawn-playful.png",
|
||||
"prompt": "Adorable fawn Puggle puppy with playful expression, compact muscular build, professional pet photography, studio lighting, photorealistic"
|
||||
},
|
||||
{
|
||||
"filename": "dog-puggle-black-sitting.png",
|
||||
"prompt": "Black and tan Puggle with alert sitting posture, pointed beagle-like ears, gentle eyes, professional studio lighting, photorealistic"
|
||||
},
|
||||
{
|
||||
"filename": "dog-puggle-cream-groomed.png",
|
||||
"prompt": "Cream Puggle freshly groomed with fluffy coat, happy expression, lying down comfortably, natural daylight, photorealistic"
|
||||
},
|
||||
{
|
||||
"filename": "dog-puggle-tricolor-outdoor.png",
|
||||
"prompt": "Tricolor Puggle in outdoor garden setting, alert playful pose, natural sunlight, professional pet photography, photorealistic"
|
||||
},
|
||||
{
|
||||
"filename": "dog-puggle-fawn-grooming.png",
|
||||
"prompt": "Fawn Puggle at grooming salon, gentle expression, compact muscular build with beagle-like features, professional grooming setup, warm lighting, photorealistic"
|
||||
}
|
||||
]
|
||||
|
||||
print(f"Generating {len(prompts)} Puggle images...")
|
||||
|
||||
for item in prompts:
|
||||
filename = item["filename"]
|
||||
prompt = item["prompt"]
|
||||
|
||||
print(f"\nGenerating {filename}...")
|
||||
|
||||
payload = {
|
||||
"model": "image-01",
|
||||
"prompt": prompt,
|
||||
"aspect_ratio": "1:1",
|
||||
"response_format": "base64",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
if "data" in data and "image_base64" in data["data"]:
|
||||
images = data["data"]["image_base64"]
|
||||
|
||||
# Save the first (and usually only) image
|
||||
output_path = f"minimax-output/{filename}"
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(base64.b64decode(images[0]))
|
||||
|
||||
file_size = os.path.getsize(output_path)
|
||||
print(f"✓ Saved {filename} ({file_size} bytes)")
|
||||
else:
|
||||
print(f"✗ Unexpected response format: {json.dumps(data, indent=2)}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"✗ Error generating {filename}: {e}")
|
||||
except Exception as e:
|
||||
print(f"✗ Unexpected error for {filename}: {e}")
|
||||
|
||||
print("\n✓ Image generation complete!")
|
||||
print("Files saved to minimax-output/")
|
||||
@@ -40,6 +40,9 @@ importers:
|
||||
nodemailer:
|
||||
specifier: ^6.9.16
|
||||
version: 6.10.1
|
||||
telnyx:
|
||||
specifier: ^6.41.0
|
||||
version: 6.41.0(ws@8.19.0)
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
@@ -2103,6 +2106,9 @@ packages:
|
||||
resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@stablelib/base64@1.0.1':
|
||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -3073,6 +3079,9 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-sha256@1.3.0:
|
||||
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
@@ -4066,6 +4075,9 @@ packages:
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
@@ -4145,6 +4157,14 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
telnyx@6.41.0:
|
||||
resolution: {integrity: sha512-93eKksI6HnLYp8e4DGlpC3SkBAfagblE+uug0FNDLT/+mix3PP0RveoQ/YZeRdxDhjMcoXVgeusJsgFP6PvUdw==}
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
peerDependenciesMeta:
|
||||
ws:
|
||||
optional: true
|
||||
|
||||
temp-dir@2.0.0:
|
||||
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6667,6 +6687,8 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@stablelib/base64@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
@@ -7703,6 +7725,8 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-sha256@1.3.0: {}
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fast-xml-builder@1.1.4:
|
||||
@@ -8690,6 +8714,11 @@ snapshots:
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
dependencies:
|
||||
'@stablelib/base64': 1.0.1
|
||||
fast-sha256: 1.3.0
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
@@ -8788,6 +8817,12 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
telnyx@6.41.0(ws@8.19.0):
|
||||
dependencies:
|
||||
standardwebhooks: 1.0.0
|
||||
optionalDependencies:
|
||||
ws: 8.19.0
|
||||
|
||||
temp-dir@2.0.0: {}
|
||||
|
||||
tempy@0.6.0:
|
||||
|
||||
|
After Width: | Height: | Size: 70 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 55 KiB |