From b696aebb368e7b438e652246459800d683e6edc2 Mon Sep 17 00:00:00 2001 From: Tom Deckers Date: Sun, 4 Dec 2022 12:15:42 +0100 Subject: [PATCH] [webexteams] Initial contribution (#13492) * [webexteams] Initial contribution Signed-off-by: Tom Deckers --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + bundles/org.openhab.binding.webexteams/NOTICE | 13 + .../org.openhab.binding.webexteams/README.md | 91 ++++ .../org.openhab.binding.webexteams/pom.xml | 17 + .../src/main/feature/feature.xml | 9 + .../webexteams/internal/WebexAuthService.java | 185 +++++++ .../webexteams/internal/WebexAuthServlet.java | 219 +++++++++ .../WebexAuthenticationException.java | 37 ++ .../internal/WebexTeamsActions.java | 233 +++++++++ .../internal/WebexTeamsBindingConstants.java | 53 ++ .../internal/WebexTeamsConfiguration.java | 39 ++ .../internal/WebexTeamsException.java | 37 ++ .../internal/WebexTeamsHandler.java | 461 ++++++++++++++++++ .../internal/WebexTeamsHandlerFactory.java | 81 +++ .../webexteams/internal/api/Message.java | 85 ++++ .../webexteams/internal/api/Person.java | 107 ++++ .../internal/api/WebexTeamsApi.java | 176 +++++++ .../internal/api/WebexTeamsApiException.java | 38 ++ .../main/resources/OH-INF/binding/binding.xml | 9 + .../OH-INF/i18n/webexteams.properties | 49 ++ .../resources/OH-INF/thing/thing-types.xml | 64 +++ .../src/main/resources/templates/account.html | 8 + .../src/main/resources/templates/index.html | 57 +++ .../src/main/resources/web/css/custom.css | 15 + .../src/main/resources/web/css/normalize.css | 427 ++++++++++++++++ .../src/main/resources/web/css/skeleton.css | 418 ++++++++++++++++ .../src/main/resources/web/images/favicon.ico | Bin 0 -> 22382 bytes bundles/pom.xml | 1 + 29 files changed, 2935 insertions(+) create mode 100644 bundles/org.openhab.binding.webexteams/NOTICE create mode 100644 bundles/org.openhab.binding.webexteams/README.md create mode 100644 bundles/org.openhab.binding.webexteams/pom.xml create mode 100644 bundles/org.openhab.binding.webexteams/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthService.java create mode 100644 bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthServlet.java create mode 100644 bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthenticationException.java create mode 100644 bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsActions.java create mode 100644 bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsBindingConstants.java create mode 100644 bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsConfiguration.java create mode 100644 bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsException.java create mode 100644 bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsHandler.java create mode 100644 bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsHandlerFactory.java create mode 100644 bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/Message.java create mode 100644 bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/Person.java create mode 100644 bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/WebexTeamsApi.java create mode 100644 bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/WebexTeamsApiException.java create mode 100644 bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/binding/binding.xml create mode 100644 bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/i18n/webexteams.properties create mode 100644 bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/thing/thing-types.xml create mode 100644 bundles/org.openhab.binding.webexteams/src/main/resources/templates/account.html create mode 100644 bundles/org.openhab.binding.webexteams/src/main/resources/templates/index.html create mode 100644 bundles/org.openhab.binding.webexteams/src/main/resources/web/css/custom.css create mode 100644 bundles/org.openhab.binding.webexteams/src/main/resources/web/css/normalize.css create mode 100644 bundles/org.openhab.binding.webexteams/src/main/resources/web/css/skeleton.css create mode 100644 bundles/org.openhab.binding.webexteams/src/main/resources/web/images/favicon.ico diff --git a/CODEOWNERS b/CODEOWNERS index d366a6e484..fb1e8477c9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -360,6 +360,7 @@ /bundles/org.openhab.binding.warmup/ @jamesmelville /bundles/org.openhab.binding.weathercompany/ @mhilbush /bundles/org.openhab.binding.weatherunderground/ @lolodomo +/bundles/org.openhab.binding.webexteams/ @tdeckers /bundles/org.openhab.binding.webthing/ @grro /bundles/org.openhab.binding.wemo/ @hmerk @jlaur /bundles/org.openhab.binding.wifiled/ @rvt @xylo diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 4b66427ac8..0af4b341a7 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -1796,6 +1796,11 @@ org.openhab.binding.weatherunderground ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.webexteams + ${project.version} + org.openhab.addons.bundles org.openhab.binding.webthing diff --git a/bundles/org.openhab.binding.webexteams/NOTICE b/bundles/org.openhab.binding.webexteams/NOTICE new file mode 100644 index 0000000000..38d625e349 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.webexteams/README.md b/bundles/org.openhab.binding.webexteams/README.md new file mode 100644 index 0000000000..f3a1093ad0 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/README.md @@ -0,0 +1,91 @@ +# WebexTeams Binding + +The Webex Team binding allows to send messages to [Webex Teams](https://web.webex.com/) users and rooms through a number of actions. + +Messages can use markdown syntax, and attachments are supported. + +## Supported Things + +- `account`: A Webex Teams account + +## Discovery + +No Things are being discovered by this binding. + + +## Thing Configuration + +Webex Teams supports two main types of app integration: + +* Bot: a separate identity that can be used to communicate with people and rooms. +* Person integration: OAuth integration that allows the binding to act on behalf of a persons. + +Both of these accounts must be first configured on the [Webex Developers](https://developer.webex.com/my-apps) website. +When creating a person integration, it's important you customize the redirect URL based on your OpenHab installation. +For example if you run your openHAB server on `http://openhab:8080` you should add [http://openhab:8080/connectwebex](http://openhab:8080/connectwebex) to the redirect URIs. + +To use a bot account, only configure the `token` (Authentication token). + +To use a person integration, configure `clientId` and `clientSecret`. +When the account is configured as a Thing in OpenHab, navigate to the redirect URL (as described above) and authorize your account. + +You shouldn't configure both an authentication token (used for bots) AND clientId/clientSecret (used for person integrations). In that case the binding will use the authentication token. + +A default room id is required for use with the `sendMessage` action. + +### `account` Thing Configuration + +| Name | Type | Description | Default | Required | Advanced | +|-----------------|---------|---------------------------------------|---------|----------|----------| +| token | text | (Bot) authentication token | N/A | no | no | +| clientId | text | (Person) client id | N/A | no | no | +| clientSecret | text | (Person) client secret | N/A | no | no | +| refreshPeriod | integer | Refresh period for channels (seconds) | 300 | no | no | +| roomId | text | ID of the default room | N/A | no | no | + +## Channels + +| Thing | channel | type | description | +|--------------------|--------------|-----------|--------------------------------------------------------------| +| WebexTeams Account | status | String | Account presence status: active, call, inactive, ... | +| WebexTeams Account | lastactivity | DateTime | The date and time of the person's last activity within Webex | + +Note: status and lastactivity are only updated for person integrations + +## Full Example + + +webexteams.things: + +Configure a bot account: + +``` +Thing webexteams:account:bot [ token="XXXXXX", roomId="YYYYYY" ] +``` + +Configure a person integration account: + +``` +Thing webexteams:account:person [ clientId="XXXXXX", clientSecret="YYYYYY", roomId="ZZZZZZ" ] +``` + +## Rule Action + +DSL rules use `getActions` to get a reference to the thing. + +`val botActions = getActions("webexteams", "webexteams:account:bot")` + +This binding includes these rule actions for sending messages: + +* `var success = botActions.sendMessage(String markdown)` - Send a message to the default room. +* `var success = botActions.sendMessage(String markdown, String attach)` - Send a message to the default room, with attachment. +* `var success = botActions.sendRoomMessage(String roomId, String markdown)` - Send a message to a specific room. +* `var success = botActions.sendRoomMessage(String roomId, String markdown, String attach)` - Send a message to a specific room, with attachment. +* `var success = botActions.sendPersonMessage(String personEmail, String markdown)` - Send a direct message to a person. +* `var success = botActions.sendPersonMessage(String personEmail, String markdown, String attach)` - Send a direct message to a person, with attachment. + +Sending messages for bot or person accounts works exactly the same. +Attachments must be URLs. +Sending local files is not supported at this moment. + + diff --git a/bundles/org.openhab.binding.webexteams/pom.xml b/bundles/org.openhab.binding.webexteams/pom.xml new file mode 100644 index 0000000000..0f8d2d7fea --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.4.0-SNAPSHOT + + + org.openhab.binding.webexteams + + openHAB Add-ons :: Bundles :: WebexTeams Binding + + diff --git a/bundles/org.openhab.binding.webexteams/src/main/feature/feature.xml b/bundles/org.openhab.binding.webexteams/src/main/feature/feature.xml new file mode 100644 index 0000000000..2e9c37b484 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.webexteams/${project.version} + + diff --git a/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthService.java b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthService.java new file mode 100644 index 0000000000..881b0f0098 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthService.java @@ -0,0 +1,185 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webexteams.internal; + +import static org.openhab.binding.webexteams.internal.WebexTeamsBindingConstants.*; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Hashtable; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; +import org.osgi.service.http.NamespaceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link WebexAuthService} class to manage the servlets and bind authorization servlet to bridges. + * + * @author Tom Deckers - Initial contribution + */ +@Component(service = WebexAuthService.class, configurationPid = "binding.webexteams.authService") +@NonNullByDefault +public class WebexAuthService { + + private static final String TEMPLATE_PATH = "templates/"; + private static final String TEMPLATE_ACCOUNT = TEMPLATE_PATH + "account.html"; + private static final String TEMPLATE_INDEX = TEMPLATE_PATH + "index.html"; + + private final Logger logger = LoggerFactory.getLogger(WebexAuthService.class); + + private final List handlers = Collections.synchronizedList(new ArrayList<>()); + + private static final String ERROR_UKNOWN_BRIDGE = "Returned 'state' by oauth redirect doesn't match any accounts. Has the account been removed?"; + + private @NonNullByDefault({}) HttpService httpService; + private @NonNullByDefault({}) BundleContext bundleContext; + + @Activate + protected void activate(ComponentContext componentContext, Map properties) { + logger.debug("Activating WebexAuthService"); + try { + bundleContext = componentContext.getBundleContext(); + httpService.registerServlet(WEBEX_ALIAS, createServlet(), new Hashtable<>(), + httpService.createDefaultHttpContext()); + httpService.registerResources(WEBEX_ALIAS + WEBEX_RES_ALIAS, "web", null); + } catch (NamespaceException | ServletException | IOException e) { + logger.warn("Error during webex auth servlet startup", e); + } + } + + @Deactivate + protected void deactivate(ComponentContext componentContext) { + logger.debug("Deactivating WebexAuthService"); + httpService.unregister(WEBEX_ALIAS); + httpService.unregister(WEBEX_ALIAS + WEBEX_RES_ALIAS); + } + + /** + * Creates a new {@link WebexAuthServlet}. + * + * @return the newly created servlet + * @throws IOException thrown when an HTML template could not be read + */ + private HttpServlet createServlet() throws IOException { + return new WebexAuthServlet(this, readTemplate(TEMPLATE_INDEX), readTemplate(TEMPLATE_ACCOUNT)); + } + + /** + * Reads a template from file and returns the content as String. + * + * @param templateName name of the template file to read + * @return The content of the template file + * @throws IOException thrown when an HTML template could not be read + */ + private String readTemplate(String templateName) throws IOException { + final URL index = bundleContext.getBundle().getEntry(templateName); + + if (index == null) { + throw new FileNotFoundException( + String.format("Cannot find '{}' - failed to initialize Webex servlet", templateName)); + } else { + try (InputStream inputStream = index.openStream()) { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + } + } + + /** + * Call with Webex redirect uri returned State and Code values to get the refresh and access tokens and persist + * these values + * + * @param servletBaseURL the servlet base, which will be the Webex redirect url + * @param state The Webex returned state value + * @param code The Webex returned code value + * @return returns the name of the Webex user that is authorized + * @throws WebexTeamsException if no handler was found for the state + */ + public String authorize(String servletBaseURL, String state, String code) throws WebexTeamsException { + logger.debug("Authorizing for state: {}, code: {}", state, code); + + final WebexTeamsHandler listener = getWebexTeamsHandler(state); + + if (listener == null) { + logger.debug( + "Webex redirected with state '{}' but no matching account was found. Possible account has been removed.", + state); + throw new WebexTeamsException(ERROR_UKNOWN_BRIDGE); + } else { + return listener.authorize(servletBaseURL, code); + } + } + + /** + * @param listener Adds the given handler + */ + public void addWebexTeamsHandler(WebexTeamsHandler listener) { + if (!handlers.contains(listener)) { + handlers.add(listener); + } + } + + /** + * @param handler Removes the given handler + */ + public void removeWebexTeamsHandler(WebexTeamsHandler handler) { + handlers.remove(handler); + } + + /** + * @return Returns all {@link WebexTeamsHandler}s. + */ + public List getWebexTeamsHandlers() { + return handlers; + } + + /** + * Get the {@link WebexTeamsHandler} that matches the given thing UID. + * + * @param thingUID UID of the thing to match the handler with + * @return the {@link WebexTeamsHandler} matching the thing UID or null + */ + private @Nullable WebexTeamsHandler getWebexTeamsHandler(String thingUID) { + final Optional maybeListener = handlers.stream().filter(l -> l.equalsThingUID(thingUID)) + .findFirst(); + return maybeListener.isPresent() ? maybeListener.get() : null; + } + + @Reference + protected void setHttpService(HttpService httpService) { + this.httpService = httpService; + } + + protected void unsetHttpService(HttpService httpService) { + this.httpService = null; + } +} diff --git a/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthServlet.java b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthServlet.java new file mode 100644 index 0000000000..4ee3318163 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthServlet.java @@ -0,0 +1,219 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webexteams.internal; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.util.MultiMap; +import org.eclipse.jetty.util.UrlEncoded; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link WebexAuthServlet} manages the authorization with the Webex API. The servlet implements the + * Authorization Code flow and saves the resulting refreshToken with the bridge. + * + * @author Tom Deckers - Initial contribution + */ +@NonNullByDefault +public class WebexAuthServlet extends HttpServlet { + static final long serialVersionUID = 42L; + private static final String CONTENT_TYPE = "text/html;charset=UTF-8"; + + // Simple HTML templates for inserting messages. + private static final String HTML_EMPTY_ACCOUNTS = "

Manually add a Webex Account to authorize it here.

"; + private static final String HTML_USER_AUTHORIZED = "

Account authorized for user %s.
"; + private static final String HTML_ERROR = "

Call to Webex failed with error: %s

"; + + private static final Pattern MESSAGE_KEY_PATTERN = Pattern.compile("\\$\\{([^\\}]+)\\}"); + + // Keys present in the index.html + private static final String KEY_PAGE_REFRESH = "pageRefresh"; + private static final String HTML_META_REFRESH_CONTENT = ""; + private static final String KEY_AUTHORIZED_USER = "authorizedUser"; + private static final String KEY_ERROR = "error"; + private static final String KEY_ACCOUNTS = "accounts"; + private static final String KEY_REDIRECT_URI = "redirectUri"; + + // Keys present in the account.html + private static final String ACCOUNT_ID = "account.id"; + private static final String ACCOUNT_NAME = "account.name"; + private static final String ACCOUNT_USER_ID = "account.user"; + private static final String ACCOUNT_TYPE = "account.type"; + private static final String ACCOUNT_AUTHORIZE = "account.authorize"; + private static final String ACCOUNT_SHOWBTN = "account.showbtn"; + private static final String ACCOUNT_SHWOMSG = "account.showmsg"; + private static final String ACCOUNT_MSG = "account.msg"; + + private final Logger logger = LoggerFactory.getLogger(WebexAuthServlet.class); + private final WebexAuthService authService; + private final String indexTemplate; + private final String accountTemplate; + + public WebexAuthServlet(WebexAuthService authService, String indexTemplate, String accountTemplate) { + this.authService = authService; + this.indexTemplate = indexTemplate; + this.accountTemplate = accountTemplate; + } + + @Override + protected void doGet(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) + throws ServletException, IOException { + if (req != null && resp != null) { + logger.debug("Webex auth callback servlet received GET request {}.", req.getRequestURI()); + final String servletBaseURL = req.getRequestURL().toString(); + final Map replaceMap = new HashMap<>(); + + handleRedirect(replaceMap, servletBaseURL, req.getQueryString()); + resp.setContentType(CONTENT_TYPE); + replaceMap.put(KEY_REDIRECT_URI, servletBaseURL); + replaceMap.put(KEY_ACCOUNTS, formatAccounts(this.accountTemplate, servletBaseURL)); + resp.getWriter().append(replaceKeysFromMap(this.indexTemplate, replaceMap)); + resp.getWriter().close(); + } + } + + /** + * Handles a possible call from Webex to the redirect_uri. If that is the case Webex will pass the authorization + * codes via the url and these are processed. In case of an error this is shown to the user. If the user was + * authorized this is passed on to the handler. Based on all these different outcomes the HTML is generated to + * inform the user. + * + * @param replaceMap a map with key String values that will be mapped in the HTML templates. + * @param servletBaseURL the servlet base, which should be used as the Webex redirect_uri value + * @param queryString the query part of the GET request this servlet is processing + */ + private void handleRedirect(Map replaceMap, String servletBaseURL, @Nullable String queryString) { + replaceMap.put(KEY_AUTHORIZED_USER, ""); + replaceMap.put(KEY_ERROR, ""); + replaceMap.put(KEY_PAGE_REFRESH, ""); + + if (queryString != null) { + final MultiMap params = new MultiMap<>(); + UrlEncoded.decodeTo(queryString, params, StandardCharsets.UTF_8.name()); + final String reqCode = params.getString("code"); + final String reqState = params.getString("state"); + final String reqError = params.getString("error"); + + replaceMap.put(KEY_PAGE_REFRESH, + params.isEmpty() ? "" : String.format(HTML_META_REFRESH_CONTENT, servletBaseURL)); + if (!reqError.isBlank()) { + logger.debug("Webex redirected with an error: {}", reqError); + replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, reqError)); + } else if (!reqState.isBlank()) { + try { + replaceMap.put(KEY_AUTHORIZED_USER, String.format(HTML_USER_AUTHORIZED, + authService.authorize(servletBaseURL, reqState, reqCode))); + } catch (WebexTeamsException e) { + logger.debug("Exception during authorizaton: ", e); + replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, e.getMessage())); + } + } + } + } + + /** + * Formats the HTML of all available Webex Accounts and returns it as a String + * + * @param accountTemplate The account template to format the account values in + * @param servletBaseURL the redirect_uri to be used in the authorization url created on the authorization button. + * @return A String with the accounts formatted with the account template + */ + private String formatAccounts(String accountTemplate, String servletBaseURL) { + final List accounts = authService.getWebexTeamsHandlers(); + + return accounts.isEmpty() ? HTML_EMPTY_ACCOUNTS + : accounts.stream().map(p -> formatAccount(accountTemplate, p, servletBaseURL)) + .collect(Collectors.joining()); + } + + /** + * Formats the HTML of a Webex Account and returns it as a String + * + * @param accountTemplate The account template to format the account values in + * @param handler The handler for the account to format + * @param servletBaseURL the redirect_uri to be used in the authorization url created on the authorization button. + * @return A String with the account formatted with the account template + */ + private String formatAccount(String accountTemplate, WebexTeamsHandler handler, String servletBaseURL) { + final Map map = new HashMap<>(); + + map.put(ACCOUNT_ID, handler.getUID().getAsString()); + map.put(ACCOUNT_NAME, handler.getLabel()); + final String webexUser = handler.getUser(); + + if (!handler.isConfigured()) { + map.put(ACCOUNT_USER_ID, ""); + map.put(ACCOUNT_SHOWBTN, "u-hide"); + map.put(ACCOUNT_SHWOMSG, "u-show"); + map.put(ACCOUNT_MSG, "Configure account."); + } else if (handler.isAuthorized()) { + map.put(ACCOUNT_USER_ID, String.format(" (Authorized user: %s)", webexUser)); + map.put(ACCOUNT_SHOWBTN, "u-hide"); + map.put(ACCOUNT_SHWOMSG, "u-show"); + map.put(ACCOUNT_MSG, "Authorized."); + } else if (!webexUser.isBlank()) { + map.put(ACCOUNT_USER_ID, String.format(" (Unauthorized user: %s)", webexUser)); + map.put(ACCOUNT_SHOWBTN, "u-show"); + map.put(ACCOUNT_SHWOMSG, "u-hide"); + map.put(ACCOUNT_MSG, ""); + } else { + map.put(ACCOUNT_USER_ID, ""); + map.put(ACCOUNT_SHOWBTN, "u-hide"); + map.put(ACCOUNT_SHWOMSG, "u-show"); + map.put(ACCOUNT_MSG, "UNKNOWN"); + } + + map.put(ACCOUNT_TYPE, handler.accountType); + map.put(ACCOUNT_AUTHORIZE, handler.formatAuthorizationUrl(servletBaseURL)); + return replaceKeysFromMap(accountTemplate, map); + } + + /** + * Replaces all keys from the map found in the template with values from the map. If the key is not found the key + * will be kept in the template. + * + * @param template template to replace keys with values + * @param map map with key value pairs to replace in the template + * @return a template with keys replaced + */ + private String replaceKeysFromMap(String template, Map map) { + final Matcher m = MESSAGE_KEY_PATTERN.matcher(template); + final StringBuffer sb = new StringBuffer(); + + while (m.find()) { + try { + final String key = m.group(1); + m.appendReplacement(sb, Matcher.quoteReplacement(map.getOrDefault(key, "${" + key + '}'))); + } catch (RuntimeException e) { + logger.debug("Error occurred during template filling, cause ", e); + } + } + m.appendTail(sb); + return sb.toString(); + } +} diff --git a/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthenticationException.java b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthenticationException.java new file mode 100644 index 0000000000..2897ac8394 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthenticationException.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webexteams.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Signals an issue with API authentication. + * + * @author Tom Deckers - Initial contribution + */ +@NonNullByDefault +public class WebexAuthenticationException extends WebexTeamsException { + static final long serialVersionUID = 44L; + + public WebexAuthenticationException() { + super(); + } + + public WebexAuthenticationException(String msg) { + super(msg); + } + + public WebexAuthenticationException(String msg, Throwable t) { + super(msg, t); + } +} diff --git a/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsActions.java b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsActions.java new file mode 100644 index 0000000000..071915a078 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsActions.java @@ -0,0 +1,233 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webexteams.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.automation.annotation.ActionInput; +import org.openhab.core.automation.annotation.ActionOutput; +import org.openhab.core.automation.annotation.RuleAction; +import org.openhab.core.thing.binding.ThingActions; +import org.openhab.core.thing.binding.ThingActionsScope; +import org.openhab.core.thing.binding.ThingHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link WebexTeamsActions} class defines rule actions for sending messages + * + * @author Tom Deckers - Initial contribution + */ +@ThingActionsScope(name = "webexteams") +@NonNullByDefault +public class WebexTeamsActions implements ThingActions { + + private final Logger logger = LoggerFactory.getLogger(WebexTeamsActions.class); + private @Nullable WebexTeamsHandler handler; + + @RuleAction(label = "@text/sendMessageActionLabel", description = "@text/sendMessageActionDescription") + public @ActionOutput(name = "success", type = "java.lang.Boolean") Boolean sendMessage( + @ActionInput(name = "text") @Nullable String text) { + if (text == null) { + logger.warn("Cannot send Message as text is missing."); + return false; + } + + final WebexTeamsHandler handler = this.handler; + if (handler == null) { + logger.debug("Handler is null, cannot send message."); + return false; + } else { + return handler.sendMessage(text); + } + } + + @RuleAction(label = "@text/sendMessageAttActionLabel", description = "@text/sendMessageAttActionDescription") + public @ActionOutput(name = "success", type = "java.lang.Boolean") Boolean sendMessage( + @ActionInput(name = "text") @Nullable String text, @ActionInput(name = "attach") @Nullable String attach) { + if (text == null) { + logger.warn("Cannot send Message as text is missing."); + return false; + } + if (attach == null) { + logger.warn("Cannot send Message as attach is missing."); + return false; + } + + final WebexTeamsHandler handler = this.handler; + if (handler == null) { + logger.debug("Handler is null, cannot send message."); + return false; + } else { + return handler.sendMessage(text, attach); + } + } + + @RuleAction(label = "@text/sendRoomMessageActionLabel", description = "@text/sendRoomMessageActionDescription") + public @ActionOutput(name = "success", type = "java.lang.Boolean") Boolean sendRoomMessage( + @ActionInput(name = "roomId") @Nullable String roomId, @ActionInput(name = "text") @Nullable String text) { + if (text == null) { + logger.warn("Cannot send Message as text is missing."); + return false; + } + if (roomId == null) { + logger.warn("Cannot send Message as roomId is missing."); + return false; + } + + final WebexTeamsHandler handler = this.handler; + if (handler == null) { + logger.debug("Handler is null, cannot send message."); + return false; + } else { + return handler.sendRoomMessage(roomId, text); + } + } + + @RuleAction(label = "@text/sendRoomMessageAttActionLabel", description = "@text/sendRoomMessageAttActionDescription") + public @ActionOutput(name = "success", type = "java.lang.Boolean") Boolean sendRoomMessage( + @ActionInput(name = "roomId") @Nullable String roomId, @ActionInput(name = "text") @Nullable String text, + @ActionInput(name = "attach") @Nullable String attach) { + if (text == null) { + logger.warn("Cannot send Message as text is missing."); + return false; + } + if (roomId == null) { + logger.warn("Cannot send Message as roomId is missing."); + return false; + } + if (attach == null) { + logger.warn("Cannot send Message as attach is missing."); + return false; + } + final WebexTeamsHandler handler = this.handler; + if (handler == null) { + logger.debug("Handler is null, cannot send message."); + return false; + } else { + return handler.sendRoomMessage(roomId, text, attach); + } + } + + @RuleAction(label = "@text/sendPersonMessageActionLabel", description = "@text/sendPersonMessageActionDescription") + public @ActionOutput(name = "success", type = "java.lang.Boolean") Boolean sendPersonMessage( + @ActionInput(name = "personEmail") @Nullable String personEmail, + @ActionInput(name = "text") @Nullable String text) { + if (text == null) { + logger.warn("Cannot send Message as text is missing."); + return false; + } + if (personEmail == null) { + logger.warn("Cannot send Message as personEmail is missing."); + return false; + } + + final WebexTeamsHandler handler = this.handler; + if (handler == null) { + logger.debug("Handler is null, cannot send message."); + return false; + } else { + return handler.sendPersonMessage(personEmail, text); + } + } + + @RuleAction(label = "@text/sendPersonMessageAttActionLabel", description = "@text/sendPersonMessageAttActionDescription") + public @ActionOutput(name = "success", type = "java.lang.Boolean") Boolean sendPersonMessage( + @ActionInput(name = "personEmail") @Nullable String personEmail, + @ActionInput(name = "text") @Nullable String text, @ActionInput(name = "attach") @Nullable String attach) { + if (text == null) { + logger.warn("Cannot send Message as text is missing."); + return false; + } + if (personEmail == null) { + logger.warn("Cannot send Message as personEmail is missing."); + return false; + } + if (attach == null) { + logger.warn("Cannot send Message as attach is missing."); + return false; + } + + final WebexTeamsHandler handler = this.handler; + if (handler == null) { + logger.debug("Handler is null, cannot send message."); + return false; + } else { + return handler.sendPersonMessage(personEmail, text, attach); + } + } + + public static boolean sendMessage(@Nullable ThingActions actions, @Nullable String text) { + if (actions instanceof WebexTeamsActions) { + return ((WebexTeamsActions) actions).sendMessage(text); + } else { + throw new IllegalArgumentException("Instance is not a WebexTeamsActions class."); + } + } + + public static boolean sendMessage(@Nullable ThingActions actions, @Nullable String text, @Nullable String attach) { + if (actions instanceof WebexTeamsActions) { + return ((WebexTeamsActions) actions).sendMessage(text, attach); + } else { + throw new IllegalArgumentException("Instance is not a WebexTeamsActions class."); + } + } + + public static boolean sendRoomMessage(@Nullable ThingActions actions, @Nullable String roomId, + @Nullable String text) { + if (actions instanceof WebexTeamsActions) { + return ((WebexTeamsActions) actions).sendRoomMessage(roomId, text); + } else { + throw new IllegalArgumentException("Instance is not a WebexTeamsActions class."); + } + } + + public static boolean sendRoomMessage(@Nullable ThingActions actions, @Nullable String roomId, + @Nullable String text, @Nullable String attach) { + if (actions instanceof WebexTeamsActions) { + return ((WebexTeamsActions) actions).sendRoomMessage(roomId, text, attach); + } else { + throw new IllegalArgumentException("Instance is not a WebexTeamsActions class."); + } + } + + public static boolean sendPersonMessage(@Nullable ThingActions actions, @Nullable String personEmail, + @Nullable String text) { + if (actions instanceof WebexTeamsActions) { + return ((WebexTeamsActions) actions).sendPersonMessage(personEmail, text); + } else { + throw new IllegalArgumentException("Instance is not a WebexTeamsActions class."); + } + } + + public static boolean sendPersonMessage(@Nullable ThingActions actions, @Nullable String personEmail, + @Nullable String text, @Nullable String attach) { + if (actions instanceof WebexTeamsActions) { + return ((WebexTeamsActions) actions).sendPersonMessage(personEmail, text, attach); + } else { + throw new IllegalArgumentException("Instance is not a WebexTeamsActions class."); + } + } + + @Override + public void setThingHandler(@Nullable ThingHandler handler) { + if (handler instanceof WebexTeamsHandler) { + this.handler = (WebexTeamsHandler) handler; + } + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return handler; + } +} diff --git a/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsBindingConstants.java b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsBindingConstants.java new file mode 100644 index 0000000000..985e523899 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsBindingConstants.java @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webexteams.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link WebexTeamsBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Tom Deckers - Initial contribution + */ +@NonNullByDefault +public class WebexTeamsBindingConstants { + + private static final String BINDING_ID = "webexteams"; + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_ACCOUNT = new ThingTypeUID(BINDING_ID, "account"); + + // List of all Channel ids + public static final String CHANNEL_STATUS = "status"; + public static final String CHANNEL_LASTACTIVITY = "lastactivity"; + + // List of properties + public static final String PROPERTY_WEBEX_NAME = "name"; + public static final String PROPERTY_WEBEX_TYPE = "type"; + + // OAuth constants + public static final String OAUTH_REDIRECT_URL = "https://files.ducbase.com/authcode/index.html"; + public static final String OAUTH_TOKEN_URL = "https://webexapis.com/v1/access_token"; + public static final String OAUTH_AUTH_URL = "https://webexapis.com/v1/authorize"; + public static final String OAUTH_AUTHORIZATION_URL = "https://webexapis.com/v1/authorize"; + public static final String OAUTH_SCOPE = "spark:all"; + public static final String WEBEX_ALIAS = "/connectwebex"; + public static final String WEBEX_RES_ALIAS = "/res"; + + public static final String WEBEX_API_ENDPOINT = "https://webexapis.com/v1"; + + // other + public static final String ISO8601_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"; +} diff --git a/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsConfiguration.java b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsConfiguration.java new file mode 100644 index 0000000000..a1cea47c3d --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsConfiguration.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webexteams.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link WebexTeamsConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Tom Deckers - Initial contribution + */ +@NonNullByDefault +public class WebexTeamsConfiguration { + // static strings used when interacting with Configuration. + public static final String TOKEN = "token"; + public static final String CLIENT_ID = "clientId"; + public static final String CLIENT_SECRET = "clientSecret"; + public static final String REFRESH_PERIOD = "refreshPeriod"; + public static final String ROOM_ID = "roomId"; + + /** + * Webex team configuration + */ + public String token = ""; + public String clientId = ""; + public String clientSecret = ""; + public int refreshPeriod = 300; + public String roomId = ""; +} diff --git a/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsException.java b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsException.java new file mode 100644 index 0000000000..fe3d2b42d2 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsException.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webexteams.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Signals a general exception in the code. + * + * @author Tom Deckers - Initial contribution + */ +@NonNullByDefault +public class WebexTeamsException extends Exception { + static final long serialVersionUID = 43L; + + public WebexTeamsException() { + super(); + } + + public WebexTeamsException(String message) { + super(message); + } + + public WebexTeamsException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsHandler.java b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsHandler.java new file mode 100644 index 0000000000..d5922259b9 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsHandler.java @@ -0,0 +1,461 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webexteams.internal; + +import static org.openhab.binding.webexteams.internal.WebexTeamsBindingConstants.*; + +import java.io.IOException; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Collection; +import java.util.Collections; +import java.util.Objects; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.webexteams.internal.api.Message; +import org.openhab.binding.webexteams.internal.api.Person; +import org.openhab.binding.webexteams.internal.api.WebexTeamsApi; +import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener; +import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.OAuthClientService; +import org.openhab.core.auth.client.oauth2.OAuthException; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.auth.client.oauth2.OAuthResponseException; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link WebexTeamsHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Tom Deckers - Initial contribution + */ +@NonNullByDefault +public class WebexTeamsHandler extends BaseThingHandler implements AccessTokenRefreshListener { + + private final Logger logger = LoggerFactory.getLogger(WebexTeamsHandler.class); + + // Object to synchronize refresh on + private final Object refreshSynchronization = new Object(); + + private @NonNullByDefault({}) WebexTeamsConfiguration config; + + private final OAuthFactory oAuthFactory; + private final HttpClient httpClient; + private @Nullable WebexTeamsApi client; + + private @Nullable OAuthClientService authService; + + private boolean configured = false; // is the handler instance properly configured? + private volatile boolean active; // is the handler instance active? + String accountType = ""; // bot or person? + + private @Nullable Future refreshFuture; + + public WebexTeamsHandler(Thing thing, OAuthFactory oAuthFactory, HttpClient httpClient) { + super(thing); + this.oAuthFactory = oAuthFactory; + this.httpClient = httpClient; + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + // No commands supported on any channel + } + + // creates list of available Actions + @Override + public Collection> getServices() { + return Collections.singletonList(WebexTeamsActions.class); + } + + @Override + public void initialize() { + logger.debug("Initializing thing {}", this.getThing().getUID()); + active = true; + config = getConfigAs(WebexTeamsConfiguration.class); + + final String token = config.token; + final String clientId = config.clientId; + final String clientSecret = config.clientSecret; + + if (!token.isBlank()) { // For bots + logger.debug("I think I'm a bot."); + try { + createBotOAuthClientService(config); + } catch (WebexTeamsException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/confErrorNotAuth"); + return; + } + } else if (!clientId.isBlank()) { // For integrations + logger.debug("I think I'm a person."); + if (clientSecret.isBlank()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/confErrorNoSecret"); + return; + } + createIntegrationOAuthClientService(config); + } else { // If no bot or integration credentials, go offline + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/confErrorTokenOrId"); + return; + } + + OAuthClientService localAuthService = this.authService; + if (localAuthService == null) { + logger.warn("authService not properly initialized"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "authService not properly initialized"); + return; + } + + updateStatus(ThingStatus.UNKNOWN); + + this.client = new WebexTeamsApi(localAuthService, httpClient); + + // Start with update status by calling Webex. If no credentials available no polling should be started. + scheduler.execute(this::startRefresh); + } + + @Override + public void dispose() { + logger.debug("Disposing thing {}", this.getThing().getUID()); + active = false; + OAuthClientService authService = this.authService; + if (authService != null) { + authService.removeAccessTokenRefreshListener(this); + } + oAuthFactory.ungetOAuthService(thing.getUID().getAsString()); + cancelSchedulers(); + } + + private void createIntegrationOAuthClientService(WebexTeamsConfiguration config) { + String thingUID = this.getThing().getUID().getAsString(); + logger.debug("Creating OAuth Client Service for {}", thingUID); + OAuthClientService service = oAuthFactory.createOAuthClientService(thingUID, OAUTH_TOKEN_URL, OAUTH_AUTH_URL, + config.clientId, config.clientSecret, OAUTH_SCOPE, false); + service.addAccessTokenRefreshListener(this); + this.authService = service; + this.configured = true; + } + + private void createBotOAuthClientService(WebexTeamsConfiguration config) throws WebexTeamsException { + String thingUID = this.getThing().getUID().getAsString(); + AccessTokenResponse response = new AccessTokenResponse(); + response.setAccessToken(config.token); + response.setScope(OAUTH_SCOPE); + response.setTokenType("Bearer"); + response.setExpiresIn(Long.MAX_VALUE); // Bot access tokens don't expire + logger.debug("Creating OAuth Client Service for {}", thingUID); + OAuthClientService service = oAuthFactory.createOAuthClientService(thingUID, OAUTH_TOKEN_URL, + OAUTH_AUTHORIZATION_URL, "not used", null, OAUTH_SCOPE, false); + try { + service.importAccessTokenResponse(response); + } catch (OAuthException e) { + throw new WebexTeamsException("Failed to create oauth client with bot token", e); + } + this.authService = service; + this.configured = true; + } + + boolean isConfigured() { + return configured; + } + + protected String authorize(String redirectUri, String reqCode) throws WebexTeamsException { + try { + logger.debug("Make call to Webex to get access token."); + + // Not doing anything with the token. It's used indirectly through authService. + OAuthClientService authService = this.authService; + if (authService != null) { + authService.getAccessTokenResponseByAuthorizationCode(reqCode, redirectUri); + } + + startRefresh(); + final String user = getUser(); + logger.info("Authorized for user: {}", user); + + return user; + } catch (RuntimeException | OAuthException | IOException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); + throw new WebexTeamsException("Failed to authorize", e); + } catch (final OAuthResponseException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); + throw new WebexTeamsException("OAuth exception", e); + } + } + + public boolean isAuthorized() { + final AccessTokenResponse accessTokenResponse = getAccessTokenResponse(); + + if ("person".equals(this.accountType)) { + return accessTokenResponse != null && accessTokenResponse.getAccessToken() != null + && accessTokenResponse.getRefreshToken() != null; + } else { + // bots don't need no refreshToken! + return accessTokenResponse != null && accessTokenResponse.getAccessToken() != null; + } + } + + private @Nullable AccessTokenResponse getAccessTokenResponse() { + try { + OAuthClientService authService = this.authService; + return authService == null ? null : authService.getAccessTokenResponse(); + } catch (OAuthException | IOException | OAuthResponseException | RuntimeException e) { + logger.debug("Exception checking authorization: ", e); + return null; + } + } + + public boolean equalsThingUID(String thingUID) { + return getThing().getUID().getAsString().equals(thingUID); + } + + public String formatAuthorizationUrl(String redirectUri) { + try { + if (this.configured) { + OAuthClientService authService = this.authService; + if (authService != null) { + return authService.getAuthorizationUrl(redirectUri, null, thing.getUID().getAsString()); + } else { + logger.warn("AuthService not properly initialized"); + return ""; + } + } else { + return ""; + } + } catch (final OAuthException e) { + logger.warn("Error constructing AuthorizationUrl: ", e); + return ""; + } + } + + // mainly used to refresh the auth token when using OAuth + private boolean refresh() { + synchronized (refreshSynchronization) { + Person person; + try { + WebexTeamsApi client = this.client; + if (client == null) { + logger.warn("Client not properly initialized"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Client not properly initialized"); + return false; + } + person = client.getPerson(); + String type = person.getType(); + if (type == null) { + type = "?"; + } + updateProperty(PROPERTY_WEBEX_TYPE, type); + this.accountType = type; + updateProperty(PROPERTY_WEBEX_NAME, person.getDisplayName()); + + // Only when the identity is a person: + if ("person".equalsIgnoreCase(person.getType())) { + String status = person.getStatus(); + updateState(CHANNEL_STATUS, StringType.valueOf(status)); + DateFormat df = new SimpleDateFormat(ISO8601_FORMAT); + String lastActivity = df.format(person.getLastActivity()); + if (lastActivity != null) { + updateState(CHANNEL_LASTACTIVITY, new DateTimeType(lastActivity)); + } + } + updateStatus(ThingStatus.ONLINE); + return true; + } catch (WebexTeamsException e) { + logger.warn("Failed to refresh: {}", e.getMessage()); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } + return false; + } + } + + private void startRefresh() { + synchronized (refreshSynchronization) { + if (refresh()) { + cancelSchedulers(); + if (active) { + refreshFuture = scheduler.scheduleWithFixedDelay(this::refresh, 0, config.refreshPeriod, + TimeUnit.SECONDS); + } + } + } + } + + /** + * Cancels all running schedulers. + */ + private synchronized void cancelSchedulers() { + Future future = this.refreshFuture; + if (future != null) { + future.cancel(true); + this.refreshFuture = null; + } + } + + public String getUser() { + return thing.getProperties().getOrDefault(PROPERTY_WEBEX_NAME, ""); + } + + public ThingUID getUID() { + return thing.getUID(); + } + + public String getLabel() { + return Objects.requireNonNullElse(thing.getLabel(), ""); + } + + /** + * Sends a message to the default room. + * + * @param msg markdown text string to be sent + * + * @return true, if sending the message has been successful and + * false in all other cases. + */ + public boolean sendMessage(String msg) { + Message message = new Message(); + message.setRoomId(config.roomId); + message.setMarkdown(msg); + logger.debug("Sending message to default room ({})", config.roomId); + return sendMessage(message); + } + + /** + * Sends a message with file attachment to the default room. + * + * @param msg markdown text string to be sent + * @param attach URL of the attachment + * + * @return true, if sending the message has been successful and + * false in all other cases. + */ + public boolean sendMessage(String msg, String attach) { + Message message = new Message(); + message.setRoomId(config.roomId); + message.setMarkdown(msg); + message.setFile(attach); + logger.debug("Sending message with attachment to default room ({})", config.roomId); + return sendMessage(message); + } + + /** + * Send a message to a specific room + * + * @param roomId roomId of the room to send to + * @param msg markdown text string to be sent + * @return true, if sending the message has been successful and + * false in all other cases. + */ + public boolean sendRoomMessage(String roomId, String msg) { + Message message = new Message(); + message.setRoomId(roomId); + message.setMarkdown(msg); + logger.debug("Sending message to room {}", roomId); + return sendMessage(message); + } + + /** + * Send a message to a specific room, with attachment + * + * @param roomId roomId of the room to send to + * @param msg markdown text string to be sent + * @param attach URL of the attachment + * + * @return true, if sending the message has been successful and + * false in all other cases. + */ + public boolean sendRoomMessage(String roomId, String msg, String attach) { + Message message = new Message(); + message.setRoomId(roomId); + message.setMarkdown(msg); + message.setFile(attach); + logger.debug("Sending message with attachment to room {}", roomId); + return sendMessage(message); + } + + /** + * Sends a message to a specific person, identified by email + * + * @param personEmail email address of the person to send to + * @param msg markdown text string to be sent + * @return true, if sending the message has been successful and + * false in all other cases. + */ + public boolean sendPersonMessage(String personEmail, String msg) { + Message message = new Message(); + message.setToPersonEmail(personEmail); + message.setMarkdown(msg); + logger.debug("Sending message to {}", personEmail); + return sendMessage(message); + } + + /** + * Sends a message to a specific person, identified by email, with attachment + * + * @param personEmail email address of the person to send to + * @param msg markdown text string to be sent + * @param attach URL of the attachment* + * @return true, if sending the message has been successful and + * false in all other cases. + */ + public boolean sendPersonMessage(String personEmail, String msg, String attach) { + Message message = new Message(); + message.setToPersonEmail(personEmail); + message.setMarkdown(msg); + message.setFile(attach); + logger.debug("Sending message to {}", personEmail); + return sendMessage(message); + } + + /** + * Sends a Message + * + * @param msg the Message to be sent + * @return true, if sending the message has been successful and + * false in all other cases. + */ + private boolean sendMessage(Message msg) { + try { + WebexTeamsApi client = this.client; + if (client != null) { + client.sendMessage(msg); + return true; + } else { + logger.warn("Client not properly initialized"); + return false; + } + } catch (WebexTeamsException e) { + logger.warn("Failed to send message: {}", e.getMessage()); + } + return false; + } + + @Override + public void onAccessTokenResponse(AccessTokenResponse tokenResponse) { + } +} diff --git a/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsHandlerFactory.java b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsHandlerFactory.java new file mode 100644 index 0000000000..a61ef52566 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsHandlerFactory.java @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webexteams.internal; + +import static org.openhab.binding.webexteams.internal.WebexTeamsBindingConstants.*; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link WebexTeamsHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Tom Deckers - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.webexteams", service = ThingHandlerFactory.class) +public class WebexTeamsHandlerFactory extends BaseThingHandlerFactory { + + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT); + + private final OAuthFactory oAuthFactory; + private final HttpClient httpClient; + private final WebexAuthService authService; + + @Activate + public WebexTeamsHandlerFactory(@Reference OAuthFactory oAuthFactory, + @Reference HttpClientFactory httpClientFactory, @Reference WebexAuthService authService) { + this.oAuthFactory = oAuthFactory; + this.httpClient = httpClientFactory.getCommonHttpClient(); + this.authService = authService; + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) { + final WebexTeamsHandler handler = new WebexTeamsHandler(thing, oAuthFactory, httpClient); + authService.addWebexTeamsHandler(handler); + return handler; + } + + return null; + } + + @Override + protected synchronized void removeHandler(ThingHandler thingHandler) { + if (thingHandler instanceof WebexTeamsHandler) { + authService.removeWebexTeamsHandler((WebexTeamsHandler) thingHandler); + } + } +} diff --git a/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/Message.java b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/Message.java new file mode 100644 index 0000000000..3274104698 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/Message.java @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webexteams.internal.api; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * a Message that is sent or received through the API. + * + * @author Tom Deckers - Initial contribution + */ +@NonNullByDefault +public class Message { + private @Nullable String id; + private @Nullable String roomId; + private @Nullable String toPersonEmail; + private @Nullable String text; + private @Nullable String markdown; + private @Nullable String file; + + @Nullable + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @Nullable + public String getRoomId() { + return roomId; + } + + public void setRoomId(String roomId) { + this.roomId = roomId; + } + + @Nullable + public String getToPersonEmail() { + return toPersonEmail; + } + + public void setToPersonEmail(String toPersonEmail) { + this.toPersonEmail = toPersonEmail; + } + + @Nullable + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + @Nullable + public String getMarkdown() { + return markdown; + } + + public void setMarkdown(String markdown) { + this.markdown = markdown; + } + + @Nullable + public String getFile() { + return file; + } + + public void setFile(String file) { + this.file = file; + } +} diff --git a/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/Person.java b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/Person.java new file mode 100644 index 0000000000..8fff760641 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/Person.java @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webexteams.internal.api; + +import java.util.Date; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * a Person object that is received from the Webex API. + * + * @author Tom Deckers - Initial contribution + */ +@NonNullByDefault +public class Person { + private @Nullable String id; + private @Nullable String displayName; + private @Nullable String firstName; + private @Nullable String lastName; + private @Nullable String avatar; + private @Nullable Date lastActivity; + private @Nullable String status; + private @Nullable String type; + + @Nullable + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @Nullable + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + @Nullable + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + @Nullable + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + @Nullable + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + + @Nullable + public Date getLastActivity() { + return lastActivity; + } + + public void setLastActivity(Date lastActivity) { + this.lastActivity = lastActivity; + } + + @Nullable + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + @Nullable + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } +} diff --git a/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/WebexTeamsApi.java b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/WebexTeamsApi.java new file mode 100644 index 0000000000..b239078adb --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/WebexTeamsApi.java @@ -0,0 +1,176 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webexteams.internal.api; + +import static org.openhab.binding.webexteams.internal.WebexTeamsBindingConstants.*; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.openhab.binding.webexteams.internal.WebexAuthenticationException; +import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.OAuthClientService; +import org.openhab.core.auth.client.oauth2.OAuthException; +import org.openhab.core.auth.client.oauth2.OAuthResponseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonIOException; +import com.google.gson.JsonSyntaxException; + +/** + * WebexTeamsApi implements API integration with Webex Teams. + * + * Not using webex-java-sdk since it's not in a public maven repo, and it doesn't easily + * support caching refresh tokens between openhab restarts, etc.. + * + * @author Tom Deckers - Initial contribution + * + */ +@NonNullByDefault +public class WebexTeamsApi { + + private final Logger logger = LoggerFactory.getLogger(WebexTeamsApi.class); + + private final OAuthClientService authService; + private final HttpClient httpClient; + + public WebexTeamsApi(OAuthClientService authService, HttpClient httpClient) { + this.authService = authService; + this.httpClient = httpClient; + } + + /** + * Get a Person object for the account. + * + * @return a Person object + * @throws WebexAuthenticationException when authentication fails + * @throws WebexTeamsApiException for other failures + */ + public Person getPerson() throws WebexTeamsApiException, WebexAuthenticationException { + URI url = getUri(WEBEX_API_ENDPOINT + "/people/me"); + + Person person = request(url, HttpMethod.GET, Person.class, null); + return person; + } + + private URI getUri(String url) throws WebexTeamsApiException { + URI uri; + try { + uri = new URI(url); + } catch (URISyntaxException e) { + throw new WebexTeamsApiException("bad url", e); + } + return uri; + } + + private O request(URI url, HttpMethod method, Class clazz, I body) + throws WebexAuthenticationException, WebexTeamsApiException { + try { + // Refresh is handled automatically by this method + AccessTokenResponse response = this.authService.getAccessTokenResponse(); + + String authToken = response == null ? null : response.getAccessToken(); + if (authToken == null) { + throw new WebexAuthenticationException("Auth token is null"); + } else { + return doRequest(url, method, authToken, clazz, body); + } + } catch (OAuthException | IOException | OAuthResponseException e) { + throw new WebexAuthenticationException("Not authenticated", e); + } + } + + private O doRequest(URI url, HttpMethod method, String authToken, Class clazz, I body) + throws WebexAuthenticationException, WebexTeamsApiException { + Gson gson = new Gson(); + try { + Request req = httpClient.newRequest(url).method(method); + req.header("Authorization", "Bearer " + authToken); + logger.debug("Requesting {} with ({}, {})", url, clazz, body); + + if (body != null) { + String bodyString = gson.toJson(body, body.getClass()); + req.content(new StringContentProvider(bodyString)); + req.header("Content-type", "application/json"); + } + + ContentResponse response = req.send(); + + logger.debug("Response: {} - {}", response.getStatus(), response.getReason()); + + if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) { + throw new WebexAuthenticationException(); + } else if (response.getStatus() == HttpStatus.OK_200) { + // Obtain the input stream on the response content + try (InputStream input = new ByteArrayInputStream(response.getContent())) { + Reader reader = new InputStreamReader(input); + O entity = gson.fromJson(reader, clazz); + return entity; + } catch (IOException | JsonIOException | JsonSyntaxException e) { + logger.warn("Exception while processing API response: {}", e.getMessage()); + throw new WebexTeamsApiException("Exception while processing API response", e); + } + } else { + logger.warn("Unexpected response {} - {}", response.getStatus(), response.getReason()); + try (InputStream input = new ByteArrayInputStream(response.getContent())) { + String text = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)).lines() + .collect(Collectors.joining("\n")); + logger.warn("Content: {}", text); + } catch (IOException e) { + throw new WebexTeamsApiException( + String.format("Unexpected response code: {}", response.getStatus()), e); + } + + throw new WebexTeamsApiException( + String.format("Unexpected response {} - {}", response.getStatus(), response.getReason())); + } + } catch (TimeoutException e) { + logger.warn("Request timeout", e); + throw new WebexTeamsApiException("Request timeout", e); + } catch (ExecutionException e) { + logger.warn("Request error", e); + throw new WebexTeamsApiException("Request error", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("Request interrupted", e); + throw new WebexTeamsApiException("Request interrupted", e); + } + } + + // sendMessage + public Message sendMessage(Message msg) throws WebexTeamsApiException, WebexAuthenticationException { + URI url = getUri(WEBEX_API_ENDPOINT + "/messages"); + Message response = request(url, HttpMethod.POST, Message.class, msg); + logger.debug("Sent message, id: {}", response.getId()); + return response; + } +} diff --git a/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/WebexTeamsApiException.java b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/WebexTeamsApiException.java new file mode 100644 index 0000000000..509983184a --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/WebexTeamsApiException.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webexteams.internal.api; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.webexteams.internal.WebexTeamsException; + +/** + * Signals a general exception with interacting with the Webex API. + * + * @author Tom Deckers - Initial contribution + */ +@NonNullByDefault +public class WebexTeamsApiException extends WebexTeamsException { + static final long serialVersionUID = 46L; + + public WebexTeamsApiException() { + super(); + } + + public WebexTeamsApiException(String message) { + super(message); + } + + public WebexTeamsApiException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/binding/binding.xml new file mode 100644 index 0000000000..850d8a8c74 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/binding/binding.xml @@ -0,0 +1,9 @@ + + + + WebexTeams Binding + This is the binding for WebexTeams. Send messages with actions. + + diff --git a/bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/i18n/webexteams.properties b/bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/i18n/webexteams.properties new file mode 100644 index 0000000000..ee0486a22d --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/i18n/webexteams.properties @@ -0,0 +1,49 @@ +# binding + +binding.webexteams.name = WebexTeams Binding +binding.webexteams.description = This is the binding for WebexTeams. Send messages with actions. + +# thing types + +thing-type.webexteams.account.label = WebexTeams Account +thing-type.webexteams.account.description = WebexTeams account used to send messages. + +# thing types config + +thing-type.config.webexteams.account.clientId.label = Client Id +thing-type.config.webexteams.account.clientId.description = Client Id. Only use with a person integration. +thing-type.config.webexteams.account.clientSecret.label = Client Secret +thing-type.config.webexteams.account.clientSecret.description = Client Secret. Only use with a person integration. +thing-type.config.webexteams.account.refreshPeriod.label = Refresh Period (seconds) +thing-type.config.webexteams.account.refreshPeriod.description = Refresh period for channels. Low numbers increase accuracy, but could hit API rate limits. Defaults to 300 secs. +thing-type.config.webexteams.account.roomId.label = Default Room Id +thing-type.config.webexteams.account.roomId.description = Id of the default room to send messages +thing-type.config.webexteams.account.token.label = Authorization Token +thing-type.config.webexteams.account.token.description = Authorization token. Only use with a bot account. + +# channel types + +channel-type.webexteams.lastactivity.label = Last Activity +channel-type.webexteams.lastactivity.description = The date and time of the person's last activity within Webex +channel-type.webexteams.status.label = Status +channel-type.webexteams.status.description = The current presence status of the person + +# actions + +confErrorTokenOrId = Either token or client id/secret must be configured +confErrorNoSecret = Using OAuth - Client secret must be configured +confErrorNoRedirectUrl = Using OAuth - RedirectUrl must be configured +confErrorNotAuth = Using OAuth - Could not authenticate +confErrorInitial = Failed initial authentication (old auth code?) +sendMessageActionLabel = send a message to the default room +sendMessageActionDescription = Sends a message to the default room +sendMessageAttActionLabel = send a message with attachment to the default room +sendMessageAttActionDescription = Sends a message with attachment to the default room +sendRoomMessageActionLabel = send a message to a specific room +sendRoomMessageActionDescription = Sends a message to a specific room +sendRoomMessageAttActionLabel = send a message with attachment to a specific room +sendRoomMessageAttActionDescription = Sends a message with attachment to a specific room +sendPersonMessageActionLabel = send a message to a person +sendPersonMessageActionDescription = Sends a message to a person +sendPersonMessageAttActionLabel = send a message with attachment to a person +sendPersonMessageAttActionDescription = Sends a message with attachment to a person diff --git a/bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 0000000000..73690907b2 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,64 @@ + + + + + + + WebexTeams account used to send messages. + + + + + + + + + + + + + + password + + Authorization token. Only use with a bot account. + + + + Client Id. Only use with a person integration. + + + password + + Client Secret. Only use with a person integration. + + + + 300 + Refresh period for channels. Low numbers increase accuracy, but could hit API rate limits. Defaults to + 300 secs. + + + + Id of the default room to send messages + + + + + + + String + + The current presence status of the person + + + + DateTime + + The date and time of the person's last activity within Webex + + + + diff --git a/bundles/org.openhab.binding.webexteams/src/main/resources/templates/account.html b/bundles/org.openhab.binding.webexteams/src/main/resources/templates/account.html new file mode 100644 index 0000000000..0a741c227e --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/resources/templates/account.html @@ -0,0 +1,8 @@ +
+
${account.type}:
+
${account.name}${account.user}
+ +
${account.msg}
+
diff --git a/bundles/org.openhab.binding.webexteams/src/main/resources/templates/index.html b/bundles/org.openhab.binding.webexteams/src/main/resources/templates/index.html new file mode 100644 index 0000000000..ddee78a426 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/resources/templates/index.html @@ -0,0 +1,57 @@ + + + + + + Authorize openHAB binding for Webex + + + ${pageRefresh} + + + + + + + + + + + + + + + + +
+
+

Authorize openHAB binding for Webex

+

On this page you can authorize your openHAB Webex Teams Account configured with the clientId and clientSecret of the Webex API on your Developer account.

+

To use this binding the following requirements apply:

+
    +
  • A Cisco Webex account.
  • +
  • Create an integration (a bot account doesn't require oAuth authorization)
  • +
+

+ The redirect URI to use with the Webex API for this openHAB installation is + ${redirectUri} +

+ +
+ + ${authorizedUser} + ${accounts} + +
+ ${error} +
+ +
+ + + diff --git a/bundles/org.openhab.binding.webexteams/src/main/resources/web/css/custom.css b/bundles/org.openhab.binding.webexteams/src/main/resources/web/css/custom.css new file mode 100644 index 0000000000..3bf627ef70 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/resources/web/css/custom.css @@ -0,0 +1,15 @@ +.bottom-one { + margin-bottom: 1cm; +} + +.u-hide { + display: none !important; } + +.u-show { + display: block !important; } + +.u-invisible { + visibility: hidden !important; } + +.u-visible { + visibility: visible !important; } \ No newline at end of file diff --git a/bundles/org.openhab.binding.webexteams/src/main/resources/web/css/normalize.css b/bundles/org.openhab.binding.webexteams/src/main/resources/web/css/normalize.css new file mode 100644 index 0000000000..81c6f31ea4 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/resources/web/css/normalize.css @@ -0,0 +1,427 @@ +/*! normalize.css v3.0.2 | MIT License | git.io/normalize */ + +/** + * 1. Set default font family to sans-serif. + * 2. Prevent iOS text size adjust after orientation change, without disabling + * user zoom. + */ + +html { + font-family: sans-serif; /* 1 */ + -ms-text-size-adjust: 100%; /* 2 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/** + * Remove default margin. + */ + +body { + margin: 0; +} + +/* HTML5 display definitions + ========================================================================== */ + +/** + * Correct `block` display not defined for any HTML5 element in IE 8/9. + * Correct `block` display not defined for `details` or `summary` in IE 10/11 + * and Firefox. + * Correct `block` display not defined for `main` in IE 11. + */ + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section, +summary { + display: block; +} + +/** + * 1. Correct `inline-block` display not defined in IE 8/9. + * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. + */ + +audio, +canvas, +progress, +video { + display: inline-block; /* 1 */ + vertical-align: baseline; /* 2 */ +} + +/** + * Prevent modern browsers from displaying `audio` without controls. + * Remove excess height in iOS 5 devices. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Address `[hidden]` styling not present in IE 8/9/10. + * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. + */ + +[hidden], +template { + display: none; +} + +/* Links + ========================================================================== */ + +/** + * Remove the gray background color from active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * Improve readability when focused and also mouse hovered in all browsers. + */ + +a:active, +a:hover { + outline: 0; +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Address styling not present in IE 8/9/10/11, Safari, and Chrome. + */ + +abbr[title] { + border-bottom: 1px dotted; +} + +/** + * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. + */ + +b, +strong { + font-weight: bold; +} + +/** + * Address styling not present in Safari and Chrome. + */ + +dfn { + font-style: italic; +} + +/** + * Address variable `h1` font-size and margin within `section` and `article` + * contexts in Firefox 4+, Safari, and Chrome. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/** + * Address styling not present in IE 8/9. + */ + +mark { + background: #ff0; + color: #000; +} + +/** + * Address inconsistent and variable font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` affecting `line-height` in all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove border when inside `a` element in IE 8/9/10. + */ + +img { + border: 0; +} + +/** + * Correct overflow not hidden in IE 9/10/11. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* Grouping content + ========================================================================== */ + +/** + * Address margin not present in IE 8/9 and Safari. + */ + +figure { + margin: 1em 40px; +} + +/** + * Address differences between Firefox and other browsers. + */ + +hr { + -moz-box-sizing: content-box; + box-sizing: content-box; + height: 0; +} + +/** + * Contain overflow in all browsers. + */ + +pre { + overflow: auto; +} + +/** + * Address odd `em`-unit font size rendering in all browsers. + */ + +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} + +/* Forms + ========================================================================== */ + +/** + * Known limitation: by default, Chrome and Safari on OS X allow very limited + * styling of `select`, unless a `border` property is set. + */ + +/** + * 1. Correct color not being inherited. + * Known issue: affects color of disabled elements. + * 2. Correct font properties not being inherited. + * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. + */ + +button, +input, +optgroup, +select, +textarea { + color: inherit; /* 1 */ + font: inherit; /* 2 */ + margin: 0; /* 3 */ +} + +/** + * Address `overflow` set to `hidden` in IE 8/9/10/11. + */ + +button { + overflow: visible; +} + +/** + * Address inconsistent `text-transform` inheritance for `button` and `select`. + * All other form control elements do not inherit `text-transform` values. + * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. + * Correct `select` style inheritance in Firefox. + */ + +button, +select { + text-transform: none; +} + +/** + * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` + * and `video` controls. + * 2. Correct inability to style clickable `input` types in iOS. + * 3. Improve usability and consistency of cursor style between image-type + * `input` and others. + */ + +button, +html input[type="button"], /* 1 */ +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; /* 2 */ + cursor: pointer; /* 3 */ +} + +/** + * Re-set default cursor for disabled elements. + */ + +button[disabled], +html input[disabled] { + cursor: default; +} + +/** + * Remove inner padding and border in Firefox 4+. + */ + +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +/** + * Address Firefox 4+ setting `line-height` on `input` using `!important` in + * the UA stylesheet. + */ + +input { + line-height: normal; +} + +/** + * It's recommended that you don't attempt to style these elements. + * Firefox's implementation doesn't respect box-sizing, padding, or width. + * + * 1. Address box sizing set to `content-box` in IE 8/9/10. + * 2. Remove excess padding in IE 8/9/10. + */ + +input[type="checkbox"], +input[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Fix the cursor style for Chrome's increment/decrement buttons. For certain + * `font-size` values of the `input`, it causes the cursor style of the + * decrement button to change from `default` to `text`. + */ + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Address `appearance` set to `searchfield` in Safari and Chrome. + * 2. Address `box-sizing` set to `border-box` in Safari and Chrome + * (include `-moz` to future-proof). + */ + +input[type="search"] { + -webkit-appearance: textfield; /* 1 */ + -moz-box-sizing: content-box; + -webkit-box-sizing: content-box; /* 2 */ + box-sizing: content-box; +} + +/** + * Remove inner padding and search cancel button in Safari and Chrome on OS X. + * Safari (but not Chrome) clips the cancel button when the search input has + * padding (and `textfield` appearance). + */ + +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * Define consistent border, margin, and padding. + */ + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** + * 1. Correct `color` not being inherited in IE 8/9/10/11. + * 2. Remove padding so people aren't caught out if they zero out fieldsets. + */ + +legend { + border: 0; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Remove default vertical scrollbar in IE 8/9/10/11. + */ + +textarea { + overflow: auto; +} + +/** + * Don't inherit the `font-weight` (applied by a rule above). + * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. + */ + +optgroup { + font-weight: bold; +} + +/* Tables + ========================================================================== */ + +/** + * Remove most spacing between table cells. + */ + +table { + border-collapse: collapse; + border-spacing: 0; +} + +td, +th { + padding: 0; +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.webexteams/src/main/resources/web/css/skeleton.css b/bundles/org.openhab.binding.webexteams/src/main/resources/web/css/skeleton.css new file mode 100644 index 0000000000..f28bf6c596 --- /dev/null +++ b/bundles/org.openhab.binding.webexteams/src/main/resources/web/css/skeleton.css @@ -0,0 +1,418 @@ +/* +* Skeleton V2.0.4 +* Copyright 2014, Dave Gamache +* www.getskeleton.com +* Free to use under the MIT license. +* http://www.opensource.org/licenses/mit-license.php +* 12/29/2014 +*/ + + +/* Table of contents +–––––––––––––––––––––––––––––––––––––––––––––––––– +- Grid +- Base Styles +- Typography +- Links +- Buttons +- Forms +- Lists +- Code +- Tables +- Spacing +- Utilities +- Clearing +- Media Queries +*/ + + +/* Grid +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +.container { + position: relative; + width: 100%; + max-width: 960px; + margin: 0 auto; + padding: 0 20px; + box-sizing: border-box; } +.column, +.columns { + width: 100%; + float: left; + box-sizing: border-box; } + +/* For devices larger than 400px */ +@media (min-width: 400px) { + .container { + width: 85%; + padding: 0; } +} + +/* For devices larger than 550px */ +@media (min-width: 550px) { + .container { + width: 80%; } + .column, + .columns { + margin-left: 4%; } + .column:first-child, + .columns:first-child { + margin-left: 0; } + + .one.column, + .one.columns { width: 4.66666666667%; } + .two.columns { width: 13.3333333333%; } + .three.columns { width: 22%; } + .four.columns { width: 30.6666666667%; } + .five.columns { width: 39.3333333333%; } + .six.columns { width: 48%; } + .seven.columns { width: 56.6666666667%; } + .eight.columns { width: 65.3333333333%; } + .nine.columns { width: 74.0%; } + .ten.columns { width: 82.6666666667%; } + .eleven.columns { width: 91.3333333333%; } + .twelve.columns { width: 100%; margin-left: 0; } + + .one-third.column { width: 30.6666666667%; } + .two-thirds.column { width: 65.3333333333%; } + + .one-half.column { width: 48%; } + + /* Offsets */ + .offset-by-one.column, + .offset-by-one.columns { margin-left: 8.66666666667%; } + .offset-by-two.column, + .offset-by-two.columns { margin-left: 17.3333333333%; } + .offset-by-three.column, + .offset-by-three.columns { margin-left: 26%; } + .offset-by-four.column, + .offset-by-four.columns { margin-left: 34.6666666667%; } + .offset-by-five.column, + .offset-by-five.columns { margin-left: 43.3333333333%; } + .offset-by-six.column, + .offset-by-six.columns { margin-left: 52%; } + .offset-by-seven.column, + .offset-by-seven.columns { margin-left: 60.6666666667%; } + .offset-by-eight.column, + .offset-by-eight.columns { margin-left: 69.3333333333%; } + .offset-by-nine.column, + .offset-by-nine.columns { margin-left: 78.0%; } + .offset-by-ten.column, + .offset-by-ten.columns { margin-left: 86.6666666667%; } + .offset-by-eleven.column, + .offset-by-eleven.columns { margin-left: 95.3333333333%; } + + .offset-by-one-third.column, + .offset-by-one-third.columns { margin-left: 34.6666666667%; } + .offset-by-two-thirds.column, + .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } + + .offset-by-one-half.column, + .offset-by-one-half.columns { margin-left: 52%; } + +} + + +/* Base Styles +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +/* NOTE +html is set to 62.5% so that all the REM measurements throughout Skeleton +are based on 10px sizing. So basically 1.5rem = 15px :) */ +html { + font-size: 62.5%; } +body { + font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ + line-height: 1.6; + font-weight: 400; + font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; + color: #222; } + + +/* Typography +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +h1, h2, h3, h4, h5, h6 { + margin-top: 0; + margin-bottom: 2rem; + font-weight: 300; } +h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;} +h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; } +h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; } +h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; } +h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; } +h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; } + +/* Larger than phablet */ +@media (min-width: 550px) { + h1 { font-size: 5.0rem; } + h2 { font-size: 4.2rem; } + h3 { font-size: 3.6rem; } + h4 { font-size: 3.0rem; } + h5 { font-size: 2.4rem; } + h6 { font-size: 1.5rem; } +} + +p { + margin-top: 0; } + + +/* Links +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +a { + color: #1EAEDB; } +a:hover { + color: #0FA0CE; } + + +/* Buttons +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +.button, +button, +input[type="submit"], +input[type="reset"], +input[type="button"] { + display: inline-block; + height: 38px; + padding: 0 30px; + color: #555; + text-align: center; + font-size: 11px; + font-weight: 600; + line-height: 38px; + letter-spacing: .1rem; + text-transform: uppercase; + text-decoration: none; + white-space: nowrap; + background-color: transparent; + border-radius: 4px; + border: 1px solid #bbb; + cursor: pointer; + box-sizing: border-box; } +.button:hover, +button:hover, +input[type="submit"]:hover, +input[type="reset"]:hover, +input[type="button"]:hover, +.button:focus, +button:focus, +input[type="submit"]:focus, +input[type="reset"]:focus, +input[type="button"]:focus { + color: #333; + border-color: #888; + outline: 0; } +.button.button-primary, +button.button-primary, +input[type="submit"].button-primary, +input[type="reset"].button-primary, +input[type="button"].button-primary { + color: #FFF; + background-color: #33C3F0; + border-color: #33C3F0; } +.button.button-primary:hover, +button.button-primary:hover, +input[type="submit"].button-primary:hover, +input[type="reset"].button-primary:hover, +input[type="button"].button-primary:hover, +.button.button-primary:focus, +button.button-primary:focus, +input[type="submit"].button-primary:focus, +input[type="reset"].button-primary:focus, +input[type="button"].button-primary:focus { + color: #FFF; + background-color: #1EAEDB; + border-color: #1EAEDB; } + + +/* Forms +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +input[type="email"], +input[type="number"], +input[type="search"], +input[type="text"], +input[type="tel"], +input[type="url"], +input[type="password"], +textarea, +select { + height: 38px; + padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ + background-color: #fff; + border: 1px solid #D1D1D1; + border-radius: 4px; + box-shadow: none; + box-sizing: border-box; } +/* Removes awkward default styles on some inputs for iOS */ +input[type="email"], +input[type="number"], +input[type="search"], +input[type="text"], +input[type="tel"], +input[type="url"], +input[type="password"], +textarea { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; } +textarea { + min-height: 65px; + padding-top: 6px; + padding-bottom: 6px; } +input[type="email"]:focus, +input[type="number"]:focus, +input[type="search"]:focus, +input[type="text"]:focus, +input[type="tel"]:focus, +input[type="url"]:focus, +input[type="password"]:focus, +textarea:focus, +select:focus { + border: 1px solid #33C3F0; + outline: 0; } +label, +legend { + display: block; + margin-bottom: .5rem; + font-weight: 600; } +fieldset { + padding: 0; + border-width: 0; } +input[type="checkbox"], +input[type="radio"] { + display: inline; } +label > .label-body { + display: inline-block; + margin-left: .5rem; + font-weight: normal; } + + +/* Lists +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +ul { + list-style: circle inside; } +ol { + list-style: decimal inside; } +ol, ul { + padding-left: 0; + margin-top: 0; } +ul ul, +ul ol, +ol ol, +ol ul { + margin: 1.5rem 0 1.5rem 3rem; + font-size: 90%; } +li { + margin-bottom: 1rem; } + + +/* Code +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +code { + padding: .2rem .5rem; + margin: 0 .2rem; + font-size: 90%; + white-space: nowrap; + background: #F1F1F1; + border: 1px solid #E1E1E1; + border-radius: 4px; } +pre > code { + display: block; + padding: 1rem 1.5rem; + white-space: pre; } + + +/* Tables +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +th, +td { + padding: 12px 15px; + text-align: left; + border-bottom: 1px solid #E1E1E1; } +th:first-child, +td:first-child { + padding-left: 0; } +th:last-child, +td:last-child { + padding-right: 0; } + + +/* Spacing +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +button, +.button { + margin-bottom: 1rem; } +input, +textarea, +select, +fieldset { + margin-bottom: 1.5rem; } +pre, +blockquote, +dl, +figure, +table, +p, +ul, +ol, +form { + margin-bottom: 2.5rem; } + + +/* Utilities +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +.u-full-width { + width: 100%; + box-sizing: border-box; } +.u-max-full-width { + max-width: 100%; + box-sizing: border-box; } +.u-pull-right { + float: right; } +.u-pull-left { + float: left; } + + +/* Misc +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +hr { + margin-top: 3rem; + margin-bottom: 3.5rem; + border-width: 0; + border-top: 1px solid #E1E1E1; } + + +/* Clearing +–––––––––––––––––––––––––––––––––––––––––––––––––– */ + +/* Self Clearing Goodness */ +.container:after, +.row:after, +.u-cf { + content: ""; + display: table; + clear: both; } + + +/* Media Queries +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +/* +Note: The best way to structure the use of media queries is to create the queries +near the relevant code. For example, if you wanted to change the styles for buttons +on small devices, paste the mobile query code up in the buttons section and style it +there. +*/ + + +/* Larger than mobile */ +@media (min-width: 400px) {} + +/* Larger than phablet (also point when grid becomes active) */ +@media (min-width: 550px) {} + +/* Larger than tablet */ +@media (min-width: 750px) {} + +/* Larger than desktop */ +@media (min-width: 1000px) {} + +/* Larger than Desktop HD */ +@media (min-width: 1200px) {} diff --git a/bundles/org.openhab.binding.webexteams/src/main/resources/web/images/favicon.ico b/bundles/org.openhab.binding.webexteams/src/main/resources/web/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ae864043e6ac018df5b2b9ed77afd6263864eef0 GIT binary patch literal 22382 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 z zoN>k(x7w@r)Yi9A*2w);vF&TH^U-NjYGkk*)_gP8g`Vu;Zr1V5wj!55T;H{CuoTx~ zJ!L}2P8o}a=!LiIjA%*6^YzQ$5x?O9^c~f`eE$0WkbYFygHf2=*S81kuKVD5XS|(t zZ&kBydk0)C97SfEZxEjS=5W2?x7PYkI_aeLI}O+1RoH*`Xh?6nt~%!U>KOP^W$$07fVH^G0&N^%(k4;Ag{w&@7()#rc zsQ(RA`{7%KZSh+c@k7ZuIx6_sc=#w^rshxA(vuUvshr%)SoBm%pZUK9LtoYR2#Pq# z_?-PnXNjI|-*`m!+V>vAc6jdLHUH50qpSTdhf}Oqc`V-rmVXKd;#$7dkZ0*d|3|<# z1@+FU;Ifvn-a~rtTDn2MN?$4LUiDS`i(mYrH}CFYtW)hK|jft`5&U zymo$klk@u>+j7~`BJg) zu%B<@_n`EFe)c5axVN?Hd)M^;7r4P6pQ?*rIc*)Dd3ast4_plgY)16h;r8T*)q9I-I(jQ7mWTdN|n)Xe~po6QigV2KYOo;0q}$}jFs8KD)?jcwAu~qH&Wq81v(b*e@2mJ22$toBWc~v+mc(kPthkblP1WBCj9?V7dOy!y_5V$CBX->ZZsUHq|T%m1C{hvUTH z%{IhNk&D&B9|zX?*tz2S!Ug#k51}#tkA180)rMc&GjiY?|I&}cHTYOHKJ6c(<2J&R zWSmWNZhp-F#reruXg29PMdsFuF54$?`7LvUlW)U@i9c^^;ZLv~bR1u?){GrGK00}m z*d)I_Z58eR6Xy^Aw(n=d1FgjyfoYlW;dX)3Z)eUfxam1OPln|+#J&FifOnhxTK<(B z2%S)JF)?>>=FZQ$ZwYPJOf1&^w-%iwUZv~UxBt}nof%B{P(>4cco2Ni{p+Uhc{$5R zQ@@kye>VI*^~%)x{k!+jeiMIY^y>Va?ciMRmUipN#a$AIwLEqFq;_8GvdeeG`TO=? z3UFtB#=EL^e*UX~HV|}a@(%^_Zw30^FFw0<+psopf%_HP@HSzd``9u~Cbnt$z2wbH z?l*7Kvp}_eG@P>Pg4T2^Ry)6IW!ceo-je<~qwSLJ|M=fq!8LygNTcUialw-QYF8~h t6ke(=ycHe`uS?qCd7gzrs0G^4p)C{wwgp0`qFd2%lCDMPJlM1E{|Cu%$Pxem literal 0 HcmV?d00001 diff --git a/bundles/pom.xml b/bundles/pom.xml index 7249eb1ebc..8f0b5b7a8c 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -393,6 +393,7 @@ org.openhab.binding.warmup org.openhab.binding.weathercompany org.openhab.binding.weatherunderground + org.openhab.binding.webexteams org.openhab.binding.webthing org.openhab.binding.wemo org.openhab.binding.wifiled -- 2.47.3