/* * Copyright (C) 2008-2023, Juick * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ package com.juick; import com.juick.model.CommandResult; import com.juick.model.User; import com.juick.service.MessagesService; import com.juick.service.StorageService; import com.juick.service.UserService; import com.juick.service.component.NotificationListener; import com.juick.service.component.PingEvent; import com.juick.service.component.SystemEvent; import com.juick.util.MessageUtils; import com.juick.util.formatters.PlainTextFormatter; import com.juick.util.xmpp.iq.MessageQuery; import com.juick.www.api.SystemActivity; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.Resource; import rocks.xmpp.addr.Jid; import rocks.xmpp.core.XmppException; import rocks.xmpp.core.session.Extension; import rocks.xmpp.core.session.XmppSession; import rocks.xmpp.core.session.XmppSessionConfiguration; import rocks.xmpp.core.session.debug.LogbackDebugger; import rocks.xmpp.core.stanza.IQHandler; import rocks.xmpp.core.stanza.model.IQ; import rocks.xmpp.core.stanza.model.Message; import rocks.xmpp.core.stanza.model.Presence; import rocks.xmpp.core.stanza.model.StanzaError; import rocks.xmpp.core.stanza.model.client.ClientMessage; import rocks.xmpp.core.stanza.model.client.ClientPresence; import rocks.xmpp.core.stanza.model.errors.Condition; import rocks.xmpp.extensions.caps.client.ClientEntityCapabilities1Protocol; import rocks.xmpp.extensions.component.accept.ExternalComponent; import rocks.xmpp.extensions.disco.ServiceDiscoveryManager; import rocks.xmpp.extensions.disco.model.info.Identity; import rocks.xmpp.extensions.filetransfer.FileTransfer; import rocks.xmpp.extensions.filetransfer.FileTransferManager; import rocks.xmpp.extensions.nick.model.Nickname; import rocks.xmpp.extensions.oob.model.x.OobX; import rocks.xmpp.extensions.ping.PingManager; import rocks.xmpp.extensions.receipts.MessageDeliveryReceiptsManager; import rocks.xmpp.extensions.si.model.StreamInitiation; import rocks.xmpp.extensions.vcard.temp.model.VCard; import rocks.xmpp.extensions.version.SoftwareVersionManager; import rocks.xmpp.extensions.version.model.SoftwareVersion; import javax.annotation.Nonnull; import java.io.IOException; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; /** * @author ugnich */ public class XMPPManager implements NotificationListener { private static final Logger logger = LoggerFactory.getLogger("XMPP"); private final ExternalComponent xmpp; private final CommandsManager commandsManager; private final MessagesService messagesService; private final UserService userService; private final Jid jid; public XMPPManager(String botJid, String componentName, int componentPort, String componentHost, String password, Resource vCardImage, CommandsManager commandsManager, StorageService storageService, MessagesService messagesService, UserService userService, Executor applicationTaskExecutor) { jid = Jid.of(botJid); this.commandsManager = commandsManager; this.messagesService = messagesService; this.userService = userService; logger.info("xmpp component start connecting to {}", componentPort); var configurationBuilder = XmppSessionConfiguration.builder() .extensions(Extension.of(com.juick.model.Message.class), Extension.of(MessageQuery.class)) .defaultResponseTimeout(Duration.ofMillis(120000)); if (logger.isTraceEnabled()) { configurationBuilder.debugger(LogbackDebugger.class); } var configuration = configurationBuilder.build(); xmpp = ExternalComponent.create(componentName, password, configuration, componentHost, componentPort); ServiceDiscoveryManager serviceDiscoveryManager = xmpp.getManager(ServiceDiscoveryManager.class); serviceDiscoveryManager.addIdentity(Identity.clientBot().withName("Juick")); xmpp.enableFeature(StreamInitiation.NAMESPACE); xmpp.getManager(ClientEntityCapabilities1Protocol.class).setNode("https://juick.com/caps"); MessageDeliveryReceiptsManager messageDeliveryReceiptsManager = xmpp.getManager(MessageDeliveryReceiptsManager.class); messageDeliveryReceiptsManager.setEnabled(true); PingManager pingManager = xmpp.getManager(PingManager.class); pingManager.setEnabled(true); SoftwareVersionManager softwareVersionManager = xmpp.getManager(SoftwareVersionManager.class); softwareVersionManager.setSoftwareVersion(new SoftwareVersion("Juick", "2.x", System.getProperty("os.name", "generic"))); VCard vCard = new VCard(); vCard.setFormattedName("Juick"); vCard.setBirthday(LocalDate.of(2008, 10, 22)); try { vCard.setUrl(new URL("http://juick.com/")); vCard.setPhoto(new VCard.Image("image/png", IOUtils.toByteArray(vCardImage.getInputStream()))); } catch (MalformedURLException e) { logger.error("invalid url", e); } catch (IOException e) { logger.warn("invalid resource", e); } xmpp.addIQHandler(new IQHandler() { public java.lang.Class getPayloadClass() { return MessageQuery.class; } public IQ handleRequest(IQ iq) { Message warningMessage = new Message(iq.getFrom(), Message.Type.CHAT); warningMessage.setFrom(jid); warningMessage.setBody("Please, stop this shit"); xmpp.send(warningMessage); return iq.createError(new StanzaError(Condition.BAD_REQUEST, "Please stop this spam")); } }); xmpp.addIQHandler(new IQHandler() { public Class getPayloadClass() { return VCard.class; } @Override public IQ handleRequest(IQ iq) { if (isValidTarget(iq.getTo(), true)) { return iq.createResult(vCard); } return iq.createError(Condition.BAD_REQUEST); } }); xmpp.addInboundMessageListener(e -> { ClientMessage result = incomingMessage(e.getMessage()); if (result != null) { xmpp.send(result); } }); FileTransferManager fileTransferManager = xmpp.getManager(FileTransferManager.class); fileTransferManager.addFileTransferOfferListener(e -> { try { List allowedTypes = List.of("png", "jpg"); String attachmentExtension = FilenameUtils.getExtension(e.getName()).toLowerCase(); String targetFilename = String.format("%s.%s", DigestUtils.md5Hex(String.format("%s-%s", e.getInitiator().toString(), e.getSessionId()).getBytes()), attachmentExtension); if (allowedTypes.contains(attachmentExtension)) { Path filePath = Paths.get(storageService.getTemporaryDirectory(), targetFilename); FileTransfer ft = e.accept(filePath).get(); ft.addFileTransferStatusListener(st -> { logger.debug("{}: received {} of {}", e.getName(), st.getBytesTransferred(), e.getSize()); if (st.getStatus().equals(FileTransfer.Status.COMPLETED)) { logger.info("transfer completed"); try { Jid initiator = e.getInitiator(); ClientMessage result = handleCommand( userService.getUserByJID(initiator.asBareJid().toEscapedString()), initiator, StringUtils.defaultString(e.getDescription()).trim(), URI.create(String.format("juick://%s", targetFilename))); if (result != null) { xmpp.send(result); } } catch (Exception e1) { logger.error("ft error", e1); } } else if (st.getStatus().equals(FileTransfer.Status.FAILED)) { logger.info("transfer failed", ft.getException()); Message msg = new Message(); msg.setType(Message.Type.CHAT); msg.setFrom(jid); msg.setTo(e.getInitiator()); msg.setBody("File transfer failed, please report to us"); xmpp.sendMessage(msg); } else if (st.getStatus().equals(FileTransfer.Status.CANCELED)) { logger.info("transfer cancelled"); } }); ft.transfer(); logger.info("transfer started"); } else { e.reject(); logger.info("transfer rejected"); } } catch (IOException | InterruptedException | ExecutionException e1) { logger.error("ft error", e1); } }); xmpp.addConnectionListener(event -> { if (event.getType().equals(rocks.xmpp.core.session.ConnectionEvent.Type.RECONNECTION_SUCCEEDED)) { logger.info("component connected"); } }); xmpp.addSessionStatusListener(event -> { logger.info("event: " + event.getStatus(), event.getThrowable()); if (event.getStatus().equals(XmppSession.Status.AUTHENTICATED)) { logger.info("Authenticated, broadcasting..."); broadcastPresence(null); } }); xmpp.addInboundPresenceListener(event -> { incomingPresence(event.getPresence()); }); applicationTaskExecutor.execute(() -> { try { xmpp.connect(); } catch (XmppException e) { logger.warn("xmpp exception", e); } }); } private boolean isValidTarget (Jid from, boolean allowDomain) { var isBotJid = from.equals(jid) || from.asBareJid().equals(jid.asBareJid()); var isDomainJid = from.asBareJid().toEscapedString().equals(jid.getDomain()); if (allowDomain) { return isBotJid || isDomainJid; } return isBotJid; }; private void sendJuickMessage(com.juick.model.Message jmsg, List users) { List jids = new ArrayList<>(); for (User user : users) { jids.addAll(userService.getJIDsbyUID(user.getUid())); } com.juick.model.Message fullMsg = messagesService.getMessage(jmsg.getMid()).orElseThrow(IllegalStateException::new); String txt = "@" + jmsg.getUser().getName() + ":" + MessageUtils.getTagsString(fullMsg) + "\n"; String attachmentUrl = MessageUtils.attachmentUrl(fullMsg); if (StringUtils.isNotEmpty(attachmentUrl)) { txt += attachmentUrl + "\n"; } txt += StringUtils.defaultString(jmsg.getText()) + "\n\n"; txt += "#" + jmsg.getMid() + " http://juick.com/m/" + jmsg.getMid(); Nickname nick = new Nickname("@" + jmsg.getUser().getName()); Message msg = new Message(); msg.setFrom(jid); msg.setBody(txt); msg.setType(Message.Type.CHAT); msg.setThread("juick-" + jmsg.getMid()); msg.addExtension(jmsg); msg.addExtension(nick); if (StringUtils.isNotEmpty(attachmentUrl)) { try { OobX oob = new OobX(new URI(attachmentUrl)); msg.addExtension(oob); } catch (URISyntaxException e) { logger.warn("uri exception", e); } } for (String jid : jids) { msg.setTo(Jid.of(jid)); xmpp.send(ClientMessage.from(msg)); } } private void sendJuickComment(com.juick.model.Message jmsg, List users) { String replyQuote; String replyTo; com.juick.model.Message replyMessage = jmsg.getReplyto() > 0 ? messagesService.getReply(jmsg.getMid(), jmsg.getReplyto()) : messagesService.getMessage(jmsg.getMid()).orElseThrow(IllegalStateException::new); replyTo = replyMessage.getUser().getName(); com.juick.model.Message fullReply = messagesService.getReply(jmsg.getMid(), jmsg.getRid()); replyQuote = StringUtils.defaultString(fullReply.getReplyQuote()); String txt = "Reply by @" + jmsg.getUser().getName() + ":\n" + replyQuote + "\n@" + replyTo + " "; String attachmentUrl = MessageUtils.attachmentUrl(fullReply); if (StringUtils.isNotEmpty(attachmentUrl)) { txt += attachmentUrl + "\n"; } txt += StringUtils.defaultString(jmsg.getText()) + "\n\n" + "#" + jmsg.getMid() + "/" + jmsg.getRid() + " http://juick.com/m/" + jmsg.getMid() + "#" + jmsg.getRid(); Message msg = new Message(); msg.setFrom(jid); msg.setBody(txt); msg.setType(Message.Type.CHAT); msg.addExtension(jmsg); for (User user : users) { for (String jid : userService.getJIDsbyUID(user.getUid())) { msg.setTo(Jid.of(jid)); xmpp.send(ClientMessage.from(msg)); } } } @Override public void processSystemEvent(SystemEvent systemEvent) { var activity = systemEvent.getActivity(); var type = activity.getType(); if (type.equals(SystemActivity.ActivityType.message)) { processMessage(activity.getMessage(), activity.getTo()); } else if (type.equals(SystemActivity.ActivityType.like)) { processLike(activity.getFrom(), activity.getMessage(), activity.getTo()); } } private void processMessage(com.juick.model.Message msg, List subscribers) { if (msg.isService()) { return; } if (MessageUtils.isPM(msg)) { userService.getJIDsbyUID(msg.getTo().getUid()) .forEach(userJid -> { Message mm = new Message(); mm.setTo(Jid.of(userJid)); mm.setType(Message.Type.CHAT); mm.setFrom(jid); mm.setBody("Private message from @" + msg.getUser().getName() + ":\n" + msg.getText()); xmpp.send(ClientMessage.from(mm)); }); } else if (MessageUtils.isReply(msg)) { sendJuickComment(msg, subscribers); } else { sendJuickMessage(msg, subscribers); } } private ClientMessage makeReply(Jid jidTo, String txt) { Message reply = new Message(); reply.setFrom(jid); reply.setTo(jidTo); reply.setType(Message.Type.CHAT); reply.setBody(txt); return ClientMessage.from(reply); } public void processLike(User liker, com.juick.model.Message jmsg, List users) { if (!userService.isInBLAny(jmsg.getUser().getUid(), liker.getUid())) { userService.getJIDsbyUID(jmsg.getUser().getUid()).forEach(authorJid -> { try { Jid toJid = Jid.of(authorJid); Message xmppMessage = new Message(); xmppMessage.setFrom(jid); xmppMessage.setTo(toJid); xmppMessage.setType(Message.Type.CHAT); xmppMessage.addExtension(jmsg); xmppMessage.setBody(String.format("%s recommended your post #%d. %s", liker.getName(), jmsg.getMid(), PlainTextFormatter.formatUrl(jmsg))); xmpp.send(ClientMessage.from(xmppMessage)); } catch (IllegalArgumentException e) { logger.warn("{}: {}", authorJid, e.getMessage()); } }); } String txt = "Recommended by @" + liker.getName() + ":\n"; txt += "@" + jmsg.getUser().getName() + ":" + MessageUtils.getTagsString(jmsg) + "\n"; String attachmentUrl = MessageUtils.attachmentUrl(jmsg); if (StringUtils.isNotEmpty(attachmentUrl)) { txt += attachmentUrl + "\n"; } txt += StringUtils.defaultString(jmsg.getText()) + "\n\n"; txt += "#" + jmsg.getMid(); if (jmsg.getReplies() > 0) { if (jmsg.getReplies() % 10 == 1 && jmsg.getReplies() % 100 != 11) { txt += " (" + jmsg.getReplies() + " reply)"; } else { txt += " (" + jmsg.getReplies() + " replies)"; } } txt += " http://juick.com/m/" + jmsg.getMid(); Nickname nick = new Nickname("@" + jmsg.getUser().getName()); Message msg = new Message(); msg.setFrom(jid); msg.setBody(txt); msg.setType(Message.Type.CHAT); msg.setThread("juick-" + jmsg.getMid()); msg.addExtension(jmsg); msg.addExtension(nick); if (StringUtils.isNotEmpty(attachmentUrl)) { try { OobX oob = new OobX(new URI(attachmentUrl)); msg.addExtension(oob); } catch (URISyntaxException e) { logger.warn("uri exception", e); } } for (User user : users) { for (String jid : userService.getJIDsbyUID(user.getUid())) { msg.setTo(Jid.of(jid)); xmpp.send(ClientMessage.from(msg)); } } } @Override public void processPingEvent(PingEvent pingEvent) { userService.getJIDsbyUID(pingEvent.getPinger().getUid()) .forEach(userJid -> { Presence p = new Presence(Jid.of(userJid)); p.setFrom(jid); p.setPriority((byte) 10); xmpp.send(ClientPresence.from(p)); }); } private void incomingPresence(Presence p) { User visitor = userService.getUserByJID(p.getFrom().asBareJid().toEscapedString()); final boolean toJuick = isValidTarget(p.getTo(), false); if (p.getType() == null) { Presence reply = new Presence(); reply.setFrom(p.getTo().asBareJid()); reply.setTo(p.getFrom().asBareJid()); reply.setType(Presence.Type.UNSUBSCRIBE); xmpp.send(ClientPresence.from(reply)); } else if (p.getType().equals(Presence.Type.PROBE)) { if (toJuick) { if (visitor != null) { userService.updateLastSeen(visitor); Presence reply = new Presence(); reply.setFrom(p.getTo().withResource(jid.getResource())); reply.setTo(p.getFrom()); reply.setPriority((byte) 10); if (!userService.getActiveJIDs().contains(p.getFrom().asBareJid().toEscapedString())) { reply.setStatus("Send ON to enable notifications"); } xmpp.send(ClientPresence.from(reply)); } else { Presence reply = new Presence(); reply.setFrom(p.getTo()); reply.setTo(p.getFrom()); reply.setType(Presence.Type.UNSUBSCRIBED); xmpp.send(ClientPresence.from(reply)); } } else { Presence reply = new Presence(); reply.setFrom(p.getTo()); reply.setTo(p.getFrom()); reply.setType(Presence.Type.UNSUBSCRIBED); xmpp.send(ClientPresence.from(reply)); } } else if (p.getType().equals(Presence.Type.SUBSCRIBE)) { if (toJuick) { Presence reply = new Presence(); reply.setFrom(p.getTo()); reply.setTo(p.getFrom()); reply.setType(Presence.Type.SUBSCRIBED); xmpp.send(ClientPresence.from(reply)); reply.setFrom(reply.getFrom().withResource(jid.getResource())); reply.setPriority((byte) 10); reply.setType(null); xmpp.send(ClientPresence.from(reply)); } else { Presence reply = new Presence(); reply.setFrom(p.getTo()); reply.setTo(p.getFrom()); reply.setType(Presence.Type.ERROR); reply.setId(p.getId()); reply.setError(new StanzaError(StanzaError.Type.CANCEL, Condition.ITEM_NOT_FOUND)); xmpp.send(ClientPresence.from(reply)); } } else if (p.getType().equals(Presence.Type.UNSUBSCRIBE)) { Presence reply = new Presence(); reply.setFrom(p.getTo()); reply.setTo(p.getFrom()); reply.setType(Presence.Type.UNSUBSCRIBED); xmpp.send(ClientPresence.from(reply)); } else if (p.getType().equals(Presence.Type.ERROR)) { StanzaError error = p.getError(); if (error != null) { var shouldDeactivate = error.getCondition().equals(Condition.REMOTE_SERVER_NOT_FOUND) || error.getType().equals(StanzaError.Type.CANCEL); if (shouldDeactivate) { if (userService.setActiveStatusForJID(p.getFrom().toEscapedString(), UserService.ActiveStatus.Inactive)) { logger.info("{} is inactive now", p.getFrom()); } } } } } public ClientMessage incomingMessage(Message msg) { if (msg.getType() != null && msg.getType().equals(Message.Type.ERROR)) { StanzaError error = msg.getError(); if (error != null) { var shouldDeactivate = error.getCondition().equals(Condition.RESOURCE_CONSTRAINT) || error.getType().equals(StanzaError.Type.CANCEL); if (shouldDeactivate) { if (userService.setActiveStatusForJID(msg.getFrom().toEscapedString(), UserService.ActiveStatus.Inactive)) { logger.info("{} is inactive now", msg.getFrom()); return null; } } } return null; } Jid to = msg.getTo(); if (isValidTarget(to, false)) { User user_from = userService.getUserByJID(msg.getFrom().asBareJid().toEscapedString()); if ((user_from == null || user_from.isAnonymous())) { return makeReply(msg.getFrom(), "XMPP registrations are disabled"); } URI attachment = URI.create(StringUtils.EMPTY); OobX oobX = msg.getExtension(OobX.class); if (oobX != null) { attachment = oobX.getUri(); } try { return handleCommand(user_from, msg.getFrom(), StringUtils.defaultString(msg.getBody()).trim(), attachment); } catch (Exception e1) { logger.warn("message exception", e1); } } ClientMessage errorMessage = ClientMessage.from(msg); errorMessage.setError(new StanzaError(StanzaError.Type.CANCEL, Condition.ITEM_NOT_FOUND)); return errorMessage; } private ClientMessage handleCommand(User user_from, Jid from, String command, @Nonnull URI attachment) { if (StringUtils.isBlank(command) && attachment.toString().isEmpty()) { return null; } messagesService.getUnread(user_from).forEach(mid -> messagesService.setRead(user_from, mid)); int commandlen = command.length(); // COMPATIBILITY if (commandlen > 7 && command.substring(0, 3).equalsIgnoreCase("PM ")) { command = command.substring(3); } try { CommandResult result = commandsManager.processCommand(user_from, command, attachment); if (StringUtils.isNotBlank(result.getText())) { return makeReply(from, result.getText()); } } catch (Exception e) { logger.warn("xmpp command exception", e); return makeReply(from, "Error processing command"); } return null; } private void broadcastPresence(Presence.Type type) { Presence presence = new Presence(); presence.setFrom(jid); if (type != null) { presence.setType(type); } userService.getActiveJIDs().forEach(j -> { try { presence.setTo(Jid.of(j)); xmpp.send(ClientPresence.from(presence)); } catch (IllegalArgumentException ex) { logger.warn("Invalid jid: {}", j, ex); } }); } public void close() throws Exception { broadcastPresence(Presence.Type.UNAVAILABLE); if (xmpp != null) { xmpp.close(); } } }