Merge pull request 'multi number sms suport in ui' (#2) from Alsan/textpipe:main into main
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -242,6 +242,15 @@
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.input-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: -8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -315,7 +324,8 @@
|
|||||||
<div class="modal-overlay" id="compose-modal" onclick="if(event.target===this)closeCompose()">
|
<div class="modal-overlay" id="compose-modal" onclick="if(event.target===this)closeCompose()">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2>New Message</h2>
|
<h2>New Message</h2>
|
||||||
<input type="tel" id="compose-to" placeholder="Phone number">
|
<input type="text" id="compose-to" placeholder="Phone numbers, separated by commas" autocomplete="off">
|
||||||
|
<small class="input-hint"> Enter multiple phone numbers separated by commas </small>
|
||||||
<textarea id="compose-msg" placeholder="Message"></textarea>
|
<textarea id="compose-msg" placeholder="Message"></textarea>
|
||||||
<p class="error-msg" id="compose-error"></p>
|
<p class="error-msg" id="compose-error"></p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
@@ -495,27 +505,88 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendCompose() {
|
async function sendCompose() {
|
||||||
const to = document.getElementById('compose-to').value.trim();
|
const toInput = document.getElementById('compose-to').value.trim();
|
||||||
const text = document.getElementById('compose-msg').value.trim();
|
const text = document.getElementById('compose-msg').value.trim();
|
||||||
if (!to || !text) { document.getElementById('compose-error').textContent = 'Fill all fields'; return; }
|
const error = document.getElementById('compose-error');
|
||||||
|
|
||||||
|
error.textContent = '';
|
||||||
|
|
||||||
|
if (!toInput) {
|
||||||
|
error.textContent = 'Please enter at least one phone number';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
error.textContent = 'Please enter a message';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split numbers by comma
|
||||||
|
const recipients = toInput
|
||||||
|
.split(',')
|
||||||
|
.map(number => number.trim())
|
||||||
|
.filter(number => number.length > 0);
|
||||||
|
|
||||||
|
// Remove duplicate numbers
|
||||||
|
const uniqueRecipients = [...new Set(recipients)];
|
||||||
|
|
||||||
|
if (uniqueRecipients.length === 0) {
|
||||||
|
error.textContent = 'Please enter at least one phone number';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic phone number validation
|
||||||
|
const invalidNumber = uniqueRecipients.find(number => {
|
||||||
|
return !/^\+?[0-9\s\-()]+$/.test(number);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidNumber) {
|
||||||
|
error.textContent = `Invalid phone number: ${invalidNumber}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendButton = document.querySelector(
|
||||||
|
'#compose-modal .modal-actions button:last-child'
|
||||||
|
);
|
||||||
|
|
||||||
|
sendButton.disabled = true;
|
||||||
|
sendButton.textContent = 'Sending...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/sms/send', {
|
const response = await fetch('/api/sms/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
headers: {
|
||||||
body: JSON.stringify({ to, text })
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': apiKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
to: uniqueRecipients,
|
||||||
|
text: text
|
||||||
|
})
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
|
||||||
closeCompose();
|
|
||||||
await loadMessages();
|
|
||||||
openThread(to);
|
|
||||||
} else {
|
|
||||||
const e = await r.json();
|
|
||||||
document.getElementById('compose-error').textContent = e.error || 'Failed';
|
|
||||||
}
|
|
||||||
} catch (e) { document.getElementById('compose-error').textContent = 'Error'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to send SMS');
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCompose();
|
||||||
|
|
||||||
|
await loadMessages();
|
||||||
|
|
||||||
|
// Open the conversation when sending to one number
|
||||||
|
if (uniqueRecipients.length === 1) {
|
||||||
|
openThread(uniqueRecipients[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
error.textContent = err.message || 'Failed to send SMS';
|
||||||
|
} finally {
|
||||||
|
sendButton.disabled = false;
|
||||||
|
sendButton.textContent = 'Send';
|
||||||
|
}
|
||||||
|
}
|
||||||
function showAbout() { document.getElementById('about-modal').style.display = 'flex'; }
|
function showAbout() { document.getElementById('about-modal').style.display = 'flex'; }
|
||||||
function closeAbout() { document.getElementById('about-modal').style.display = 'none'; }
|
function closeAbout() { document.getElementById('about-modal').style.display = 'none'; }
|
||||||
|
|
||||||
@@ -531,4 +602,4 @@
|
|||||||
setInterval(() => { if (apiKey) { loadMessages(); } }, 15000);
|
setInterval(() => { if (apiKey) { loadMessages(); } }, 15000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ object StringOrListSerializer : KSerializer<List<String>> {
|
|||||||
|
|
||||||
return when (val element = jsonDecoder.decodeJsonElement()) {
|
return when (val element = jsonDecoder.decodeJsonElement()) {
|
||||||
is JsonPrimitive -> listOf(element.content)
|
is JsonPrimitive -> listOf(element.content)
|
||||||
is JsonArray -> element.map { it.jsonPrimitive.content }
|
|
||||||
|
is JsonArray -> element.map {
|
||||||
|
it.jsonPrimitive.content
|
||||||
|
}
|
||||||
|
|
||||||
else -> throw SerializationException(
|
else -> throw SerializationException(
|
||||||
"'to' must be a phone number string or an array of phone numbers"
|
"'to' must be a phone number string or an array of phone numbers"
|
||||||
)
|
)
|
||||||
@@ -45,8 +49,6 @@ object StringOrListSerializer : KSerializer<List<String>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SendSmsRequest(
|
data class SendSmsRequest(
|
||||||
@Serializable(with = StringOrListSerializer::class)
|
@Serializable(with = StringOrListSerializer::class)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import io.ktor.server.routing.*
|
|||||||
import sh.sar.textpipe.data.model.ErrorResponse
|
import sh.sar.textpipe.data.model.ErrorResponse
|
||||||
import sh.sar.textpipe.data.model.MessagesResponse
|
import sh.sar.textpipe.data.model.MessagesResponse
|
||||||
import sh.sar.textpipe.data.model.SendSmsRequest
|
import sh.sar.textpipe.data.model.SendSmsRequest
|
||||||
|
import sh.sar.textpipe.data.model.SendSmsResponse
|
||||||
import sh.sar.textpipe.data.repository.SmsRepository
|
import sh.sar.textpipe.data.repository.SmsRepository
|
||||||
import sh.sar.textpipe.server.auth.ApiKeyAttributeKey
|
import sh.sar.textpipe.server.auth.ApiKeyAttributeKey
|
||||||
import sh.sar.textpipe.server.auth.SimSlotAttributeKey
|
import sh.sar.textpipe.server.auth.SimSlotAttributeKey
|
||||||
@@ -15,42 +16,87 @@ fun Route.smsRoutes(smsRepository: SmsRepository) {
|
|||||||
post("/api/sms/send") {
|
post("/api/sms/send") {
|
||||||
val apiKey = call.attributes.getOrNull(ApiKeyAttributeKey)
|
val apiKey = call.attributes.getOrNull(ApiKeyAttributeKey)
|
||||||
if (apiKey == null) {
|
if (apiKey == null) {
|
||||||
call.respond(HttpStatusCode.Unauthorized, ErrorResponse("Missing API key"))
|
call.respond(
|
||||||
|
HttpStatusCode.Unauthorized,
|
||||||
|
ErrorResponse("Missing API key")
|
||||||
|
)
|
||||||
return@post
|
return@post
|
||||||
}
|
}
|
||||||
|
|
||||||
val request = try {
|
val request = try {
|
||||||
call.receive<SendSmsRequest>()
|
call.receive<SendSmsRequest>()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body: ${e.message}"))
|
call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorResponse("Invalid request body: ${e.message}")
|
||||||
|
)
|
||||||
return@post
|
return@post
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate request
|
// Validate recipients
|
||||||
if (request.to.isEmpty()) {
|
if (request.to.isEmpty()) {
|
||||||
call.respond(HttpStatusCode.BadRequest, ErrorResponse("'to' field is required"))
|
call.respond(
|
||||||
return@post
|
HttpStatusCode.BadRequest,
|
||||||
}
|
ErrorResponse("'to' field is required")
|
||||||
|
)
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove empty numbers and duplicates
|
||||||
|
val recipients = request.to
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
if (recipients.isEmpty()) {
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorResponse("'to' must contain at least one phone number")
|
||||||
|
)
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message
|
||||||
if (request.text.isBlank()) {
|
if (request.text.isBlank()) {
|
||||||
call.respond(HttpStatusCode.BadRequest, ErrorResponse("'text' field is required"))
|
call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorResponse("'text' field is required")
|
||||||
|
)
|
||||||
return@post
|
return@post
|
||||||
}
|
}
|
||||||
|
|
||||||
val responses = request.to.mapNotNull { number ->
|
val responses = mutableListOf<SendSmsResponse>()
|
||||||
smsRepository.sendSms(number, request.text, apiKey)
|
val failed = mutableListOf<String>()
|
||||||
}
|
|
||||||
|
|
||||||
if (responses.isEmpty()) {
|
for (number in recipients) {
|
||||||
call.respond(
|
val response = smsRepository.sendSms(
|
||||||
HttpStatusCode.InternalServerError,
|
number,
|
||||||
ErrorResponse("Failed to send SMS")
|
request.text,
|
||||||
)
|
apiKey
|
||||||
return@post
|
)
|
||||||
}
|
|
||||||
|
|
||||||
call.respond(HttpStatusCode.OK, responses)
|
if (response != null) {
|
||||||
|
responses.add(response)
|
||||||
|
} else {
|
||||||
|
failed.add(number)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All failed
|
||||||
|
if (responses.isEmpty()) {
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.InternalServerError,
|
||||||
|
ErrorResponse("Failed to send SMS to all recipients")
|
||||||
|
)
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least one succeeded
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.OK,
|
||||||
|
responses
|
||||||
|
)
|
||||||
|
}
|
||||||
get("/api/sms/messages") {
|
get("/api/sms/messages") {
|
||||||
val simSlot = call.attributes.getOrNull(SimSlotAttributeKey)
|
val simSlot = call.attributes.getOrNull(SimSlotAttributeKey)
|
||||||
if (simSlot == null) {
|
if (simSlot == null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user