mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-08-21 04:46:06 +00:00
* Add sendStory method for posting file attachment stories to My Story Adds Manager.sendStory(attachment, allowsReplies), which uploads a file attachment, builds a SignalServiceStoryMessage, and sends it to all registered, non-blocked, non-hidden contacts that haven't opted out of seeing the user's story (Contact.hideStory), excluding self. SendHelper gains sendStoryMessage(), which resolves recipient addresses and unidentified access and delegates to SignalServiceMessageSender.sendGroupStory() against DistributionId.MY_STORY, following the same address/access resolution pattern used for group sends. A sync transcript is sent afterwards via sendStorySyncMessage() so linked devices see the story was posted. This is core library plumbing only; no CLI command, stub implementations, or documentation are added yet. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs * Add sendStory command for posting stories via CLI and JSON-RPC Implements SendStoryCommand to allow users to post stories through the CLI and JSON-RPC interfaces. Command accepts an attachment file path (required) and optional --no-replies flag to disable replies on the story. Handles AttachmentInvalidException and IOException appropriately and outputs results using SendMessageResultUtils. Registered in Commands.java in alphabetical order. * Add sendStory stubs to DbusManagerImpl and StubManager Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs * Document sendStory in man page and changelog * Validate story attachment MIME type and fix D-Bus stub - Reject non-image/video attachments before uploading, since stories only support image and video content - Change DbusManagerImpl.sendStory to throw UnsupportedOperationException to match the pattern used by all other unimplemented D-Bus methods * Resolve recipients before uploading story attachment Move recipient resolution ahead of the attachment upload so that an empty contact list is caught early without wasting bandwidth on an upload that would reach nobody. * Address review feedback: fix hideStory filter and remove redundant sync - Fix hideStory filter to require contact exists (!=null &&) instead of permitting null contacts (==null ||), matching the intent of filtering to contacts who haven't hidden stories - Remove manual sendStorySyncMessage call, as the library's sendStory already handles sync internally Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHzM2XLKQoX9iraEdhoh3h --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
542 lines
16 KiB
Java
542 lines
16 KiB
Java
package org.asamk.signal.http;
|
|
|
|
import org.asamk.signal.manager.Manager;
|
|
import org.asamk.signal.manager.api.CallInfo;
|
|
import org.asamk.signal.manager.api.CallOffer;
|
|
import org.asamk.signal.manager.api.Configuration;
|
|
import org.asamk.signal.manager.api.Device;
|
|
import org.asamk.signal.manager.api.DeviceLinkUrl;
|
|
import org.asamk.signal.manager.api.Group;
|
|
import org.asamk.signal.manager.api.GroupId;
|
|
import org.asamk.signal.manager.api.GroupInviteLinkUrl;
|
|
import org.asamk.signal.manager.api.Identity;
|
|
import org.asamk.signal.manager.api.IdentityVerificationCode;
|
|
import org.asamk.signal.manager.api.Message;
|
|
import org.asamk.signal.manager.api.MessageEnvelope;
|
|
import org.asamk.signal.manager.api.Pair;
|
|
import org.asamk.signal.manager.api.ReceiveConfig;
|
|
import org.asamk.signal.manager.api.Recipient;
|
|
import org.asamk.signal.manager.api.RecipientIdentifier;
|
|
import org.asamk.signal.manager.api.SendGroupMessageResults;
|
|
import org.asamk.signal.manager.api.SendMessageResult;
|
|
import org.asamk.signal.manager.api.SendMessageResults;
|
|
import org.asamk.signal.manager.api.StickerPack;
|
|
import org.asamk.signal.manager.api.StickerPackId;
|
|
import org.asamk.signal.manager.api.StickerPackUrl;
|
|
import org.asamk.signal.manager.api.TurnServer;
|
|
import org.asamk.signal.manager.api.TypingAction;
|
|
import org.asamk.signal.manager.api.UpdateGroup;
|
|
import org.asamk.signal.manager.api.UpdateProfile;
|
|
import org.asamk.signal.manager.api.UserStatus;
|
|
import org.asamk.signal.manager.api.UsernameLinkUrl;
|
|
import org.asamk.signal.manager.api.UsernameStatus;
|
|
import org.junit.jupiter.api.AfterEach;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
import java.io.File;
|
|
import java.io.InputStream;
|
|
import java.net.HttpURLConnection;
|
|
import java.net.InetSocketAddress;
|
|
import java.net.ServerSocket;
|
|
import java.net.URI;
|
|
import java.time.Duration;
|
|
import java.util.Collection;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Optional;
|
|
import java.util.Set;
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
/**
|
|
* Regression test for the SSE initial-flush bug:
|
|
* HttpServerHandler used to flush the initial SSE response only after a later
|
|
* write in the 15-second keep-alive loop, meaning the HTTP response headers
|
|
* were not flushed to the client until then.
|
|
* Clients with a shorter connection timeout (e.g. 10 s) would time out before
|
|
* receiving the initial response.
|
|
*
|
|
* This test verifies that the endpoint returns HTTP 200 within 2 seconds of
|
|
* connecting to GET /api/v1/events.
|
|
*/
|
|
class SseInitialFlushTest {
|
|
|
|
private HttpServerHandler handler;
|
|
private int port;
|
|
|
|
/** Finds a free local port. */
|
|
private static int freePort() throws Exception {
|
|
try (var ss = new ServerSocket(0)) {
|
|
ss.setReuseAddress(true);
|
|
return ss.getLocalPort();
|
|
}
|
|
}
|
|
|
|
@BeforeEach
|
|
void setUp() throws Exception {
|
|
port = freePort();
|
|
handler = new HttpServerHandler(new InetSocketAddress("127.0.0.1", port), new MinimalStubManager());
|
|
handler.init();
|
|
}
|
|
|
|
@AfterEach
|
|
void tearDown() {
|
|
if (handler != null) {
|
|
handler.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The SSE endpoint MUST flush the initial HTTP response immediately upon
|
|
* connection, before the 15-second keep-alive loop fires. A read timeout of
|
|
* 2 000 ms is used — well below the 15-second wait interval but generous
|
|
* enough to survive any CI scheduling jitter.
|
|
*/
|
|
@Test
|
|
void sseEndpointReturnsHeadersWithinTwoSeconds() {
|
|
assertDoesNotThrow(() -> {
|
|
var url = new URI("http", null, "127.0.0.1", port, "/api/v1/events", null, null).toURL();
|
|
var conn = (HttpURLConnection) url.openConnection();
|
|
conn.setRequestProperty("Accept", "text/event-stream");
|
|
conn.setReadTimeout(2_000); // 2 s — fails before fix (15 s flush), passes after
|
|
conn.setConnectTimeout(2_000);
|
|
try {
|
|
conn.connect();
|
|
assertEquals(200, conn.getResponseCode());
|
|
} finally {
|
|
conn.disconnect();
|
|
}
|
|
}, "SSE endpoint did not return the initial response within 2 seconds");
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Minimal Manager stub — only receive-handler methods need real behaviour;
|
|
// everything else is a no-op stub.
|
|
// -------------------------------------------------------------------------
|
|
|
|
private static final class MinimalStubManager implements Manager {
|
|
|
|
@Override
|
|
public String getSelfNumber() {
|
|
return "+10000000000";
|
|
}
|
|
|
|
@Override
|
|
public void addReceiveHandler(ReceiveMessageHandler handler, boolean isWeakListener) {
|
|
// no-op
|
|
}
|
|
|
|
@Override
|
|
public void removeReceiveHandler(ReceiveMessageHandler handler) {
|
|
// no-op
|
|
}
|
|
|
|
@Override
|
|
public boolean isReceiving() {
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public void receiveMessages(Optional<Duration> timeout, Optional<Integer> maxMessages, ReceiveMessageHandler handler) {
|
|
}
|
|
|
|
@Override
|
|
public void stopReceiveMessages() {
|
|
}
|
|
|
|
@Override
|
|
public void setReceiveConfig(ReceiveConfig receiveConfig) {
|
|
}
|
|
|
|
@Override
|
|
public Map<String, UserStatus> getUserStatus(Set<String> numbers) {
|
|
return Map.of();
|
|
}
|
|
|
|
@Override
|
|
public Map<String, UsernameStatus> getUsernameStatus(Set<String> usernames) {
|
|
return Map.of();
|
|
}
|
|
|
|
@Override
|
|
public void updateAccountAttributes(String deviceName, Boolean unidentifiedDeliveryIndicators, Boolean discoverableByNumber, Boolean numberSharing) {
|
|
}
|
|
|
|
@Override
|
|
public Configuration getConfiguration() {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public void updateConfiguration(Configuration configuration) {
|
|
}
|
|
|
|
@Override
|
|
public void updateProfile(UpdateProfile updateProfile) {
|
|
}
|
|
|
|
@Override
|
|
public String getUsername() {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public UsernameLinkUrl getUsernameLink() {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public void setUsername(String username) {
|
|
}
|
|
|
|
@Override
|
|
public void deleteUsername() {
|
|
}
|
|
|
|
@Override
|
|
public void startChangeNumber(String newNumber, boolean voiceVerification, String captcha) {
|
|
}
|
|
|
|
@Override
|
|
public void finishChangeNumber(String newNumber, String verificationCode, String pin) {
|
|
}
|
|
|
|
@Override
|
|
public void unregister() {
|
|
}
|
|
|
|
@Override
|
|
public void deleteAccount() {
|
|
}
|
|
|
|
@Override
|
|
public void submitRateLimitRecaptchaChallenge(String challenge, String captcha) {
|
|
}
|
|
|
|
@Override
|
|
public List<Device> getLinkedDevices() {
|
|
return List.of();
|
|
}
|
|
|
|
@Override
|
|
public void updateLinkedDevice(int deviceId, String name) {
|
|
}
|
|
|
|
@Override
|
|
public void removeLinkedDevices(int deviceId) {
|
|
}
|
|
|
|
@Override
|
|
public void addDeviceLink(DeviceLinkUrl deviceLinkUrl) {
|
|
}
|
|
|
|
@Override
|
|
public void setRegistrationLockPin(Optional<String> pin) {
|
|
}
|
|
|
|
@Override
|
|
public List<Group> getGroups() {
|
|
return List.of();
|
|
}
|
|
|
|
@Override
|
|
public List<Group> getGroups(Collection<GroupId> groupIds) {
|
|
return List.of();
|
|
}
|
|
|
|
@Override
|
|
public SendGroupMessageResults quitGroup(GroupId groupId, Set<RecipientIdentifier.Single> administrators) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public void deleteGroup(GroupId groupId) {
|
|
}
|
|
|
|
@Override
|
|
public Pair<GroupId, SendGroupMessageResults> createGroup(String name, Set<RecipientIdentifier.Single> members, String avatarFile) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendGroupMessageResults updateGroup(GroupId groupId, UpdateGroup updateGroup) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public Pair<GroupId, SendGroupMessageResults> joinGroup(GroupInviteLinkUrl inviteLinkUrl) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendTypingMessage(TypingAction action, Set<RecipientIdentifier> recipients) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendReadReceipt(RecipientIdentifier.Single sender, List<Long> messageIds) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendViewedReceipt(RecipientIdentifier.Single sender, List<Long> messageIds) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendMessage(Message message, Set<RecipientIdentifier> recipients, boolean notifySelf) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendEditMessage(Message message, Set<RecipientIdentifier> recipients, long targetSentTimestamp) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendRemoteDeleteMessage(long targetSentTimestamp, Set<RecipientIdentifier> recipients) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendMessageReaction(String emoji, boolean remove, RecipientIdentifier.Single targetAuthor, long targetSentTimestamp, Set<RecipientIdentifier> recipients, boolean notifySelf, boolean story) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendAdminDelete(RecipientIdentifier.Single targetAuthor, long targetSentTimestamp, Set<RecipientIdentifier.Group> recipients, boolean notifySelf, boolean story) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendPinMessage(int duration, RecipientIdentifier.Single targetAuthor, long targetSentTimestamp, Set<RecipientIdentifier> recipients, boolean notifySelf, boolean story) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendUnpinMessage(RecipientIdentifier.Single targetAuthor, long targetSentTimestamp, Set<RecipientIdentifier> recipients, boolean notifySelf, boolean story) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendPaymentNotificationMessage(byte[] receipt, String note, RecipientIdentifier.Single recipient) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public void sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) {
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendMessageRequestResponse(MessageEnvelope.Sync.MessageRequestResponse.Type type, Set<RecipientIdentifier> recipients) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendPollCreateMessage(String question, boolean multipleChoice, List<String> options, Set<RecipientIdentifier> recipients, boolean notifySelf) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendPollVoteMessage(RecipientIdentifier.Single author, long timestamp, List<Integer> optionIds, int version, Set<RecipientIdentifier> recipients, boolean notifySelf) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendPollTerminateMessage(long timestamp, Set<RecipientIdentifier> recipients, boolean notifySelf) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResults sendStory(String attachment, boolean allowsReplies) {
|
|
return new SendMessageResults(0, Map.of());
|
|
}
|
|
|
|
@Override
|
|
public void hideRecipient(RecipientIdentifier.Single recipient) {
|
|
}
|
|
|
|
@Override
|
|
public void deleteRecipient(RecipientIdentifier.Single recipient) {
|
|
}
|
|
|
|
@Override
|
|
public void deleteContact(RecipientIdentifier.Single recipient) {
|
|
}
|
|
|
|
@Override
|
|
public void setContactName(RecipientIdentifier.Single recipient, String givenName, String familyName, String newGivenName, String newFamilyName, String nick) {
|
|
}
|
|
|
|
@Override
|
|
public void setContactsBlocked(Collection<RecipientIdentifier.Single> recipients, boolean blocked) {
|
|
}
|
|
|
|
@Override
|
|
public void setGroupsBlocked(Collection<GroupId> groupIds, boolean blocked) {
|
|
}
|
|
|
|
@Override
|
|
public void setExpirationTimer(RecipientIdentifier.Single recipient, int messageExpirationTimer) {
|
|
}
|
|
|
|
@Override
|
|
public StickerPackUrl uploadStickerPack(File path) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public void installStickerPack(StickerPackUrl url) {
|
|
}
|
|
|
|
@Override
|
|
public List<StickerPack> getStickerPacks() {
|
|
return List.of();
|
|
}
|
|
|
|
@Override
|
|
public void requestAllSyncData() {
|
|
}
|
|
|
|
@Override
|
|
public boolean isContactBlocked(RecipientIdentifier.Single recipient) {
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public void sendContacts() {
|
|
}
|
|
|
|
@Override
|
|
public List<Recipient> getRecipients(boolean onlyWithProfile, Optional<Boolean> blocked, Collection<RecipientIdentifier.Single> addresses, Optional<String> name) {
|
|
return List.of();
|
|
}
|
|
|
|
@Override
|
|
public String getContactOrProfileName(RecipientIdentifier.Single recipient) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public Group getGroup(GroupId groupId) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public List<Identity> getIdentities() {
|
|
return List.of();
|
|
}
|
|
|
|
@Override
|
|
public List<Identity> getIdentities(RecipientIdentifier.Single recipient) {
|
|
return List.of();
|
|
}
|
|
|
|
@Override
|
|
public boolean trustIdentityVerified(RecipientIdentifier.Single recipient, IdentityVerificationCode verificationCode) {
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public boolean trustIdentityAllKeys(RecipientIdentifier.Single recipient) {
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public void addAddressChangedListener(Runnable listener) {
|
|
}
|
|
|
|
@Override
|
|
public void addClosedListener(Runnable listener) {
|
|
}
|
|
|
|
@Override
|
|
public InputStream retrieveAttachment(String id) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public InputStream retrieveContactAvatar(RecipientIdentifier.Single recipient) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public InputStream retrieveProfileAvatar(RecipientIdentifier.Single recipient) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public InputStream retrieveGroupAvatar(GroupId groupId) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public InputStream retrieveSticker(StickerPackId stickerPackId, int stickerId) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public CallInfo startCall(RecipientIdentifier.Single recipient) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public CallInfo acceptCall(long callId) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public void hangupCall(long callId) {
|
|
}
|
|
|
|
@Override
|
|
public SendMessageResult rejectCall(long callId) {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public List<CallInfo> listActiveCalls() {
|
|
return List.of();
|
|
}
|
|
|
|
@Override
|
|
public void sendCallOffer(RecipientIdentifier.Single recipient, CallOffer callOffer) {
|
|
}
|
|
|
|
@Override
|
|
public void sendCallAnswer(RecipientIdentifier.Single recipient, long callId, byte[] answer) {
|
|
}
|
|
|
|
@Override
|
|
public void sendIceUpdate(RecipientIdentifier.Single recipient, long callId, List<byte[]> iceCandidates) {
|
|
}
|
|
|
|
@Override
|
|
public void sendHangup(RecipientIdentifier.Single recipient, long callId, MessageEnvelope.Call.Hangup.Type type) {
|
|
}
|
|
|
|
@Override
|
|
public void sendBusy(RecipientIdentifier.Single recipient, long callId) {
|
|
}
|
|
|
|
@Override
|
|
public List<TurnServer> getTurnServerInfo() {
|
|
return List.of();
|
|
}
|
|
|
|
@Override
|
|
public void close() {
|
|
}
|
|
|
|
@Override
|
|
public void addCallEventListener(CallEventListener listener) {
|
|
}
|
|
|
|
@Override
|
|
public void removeCallEventListener(CallEventListener listener) {
|
|
}
|
|
}
|
|
}
|