diff --git a/CODEOWNERS b/CODEOWNERS index d366a6e4840..fb1e8477c99 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 4b66427ac82..0af4b341a7c 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 00000000000..38d625e3492 --- /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 00000000000..f3a1093ad0f --- /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 00000000000..0f8d2d7fea3 --- /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 00000000000..2e9c37b4846 --- /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 00000000000..881b0f00989 --- /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 00000000000..4ee33181635 --- /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 00000000000..2897ac8394e --- /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 00000000000..071915a078a --- /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 00000000000..985e523899d --- /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 00000000000..a1cea47c3dd --- /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 00000000000..fe3d2b42d2c --- /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 00000000000..d5922259b98 --- /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 00000000000..a61ef52566d --- /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 00000000000..32741046986 --- /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 00000000000..8fff7606415 --- /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 00000000000..b239078adb1 --- /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 00000000000..509983184a4 --- /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 00000000000..850d8a8c747 --- /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 00000000000..ee0486a22dd --- /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 00000000000..73690907b2a --- /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 00000000000..0a741c227e2 --- /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 00000000000..ddee78a4265 --- /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 00000000000..3bf627ef706 --- /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 00000000000..81c6f31ea4b --- /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 00000000000..f28bf6c596e --- /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 00000000000..ae864043e6a Binary files /dev/null and b/bundles/org.openhab.binding.webexteams/src/main/resources/web/images/favicon.ico differ diff --git a/bundles/pom.xml b/bundles/pom.xml index 7249eb1ebca..8f0b5b7a8c8 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