]> git.basschouten.com Git - openhab-addons.git/commitdiff
[webexteams] Initial contribution (#13492)
authorTom Deckers <tom@ducbase.com>
Sun, 4 Dec 2022 11:15:42 +0000 (12:15 +0100)
committerGitHub <noreply@github.com>
Sun, 4 Dec 2022 11:15:42 +0000 (12:15 +0100)
* [webexteams] Initial contribution

Signed-off-by: Tom Deckers <tom@ducbase.com>
29 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.webexteams/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/README.md [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthService.java [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthServlet.java [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexAuthenticationException.java [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsActions.java [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsException.java [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/WebexTeamsHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/Message.java [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/Person.java [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/WebexTeamsApi.java [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/java/org/openhab/binding/webexteams/internal/api/WebexTeamsApiException.java [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/binding/binding.xml [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/i18n/webexteams.properties [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/resources/templates/account.html [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/resources/templates/index.html [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/resources/web/css/custom.css [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/resources/web/css/normalize.css [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/resources/web/css/skeleton.css [new file with mode: 0644]
bundles/org.openhab.binding.webexteams/src/main/resources/web/images/favicon.ico [new file with mode: 0644]
bundles/pom.xml

index d366a6e4840390cf3bda77c1543177c78eae78dd..fb1e8477c9964dd9fe7a6710ec66fc4014d658a5 100644 (file)
 /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
index 4b66427ac82d93f03c438693802dbcde9fe5680b..0af4b341a7c4969c8e595e076f4745bd326d8a42 100644 (file)
       <artifactId>org.openhab.binding.weatherunderground</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.webexteams</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.webthing</artifactId>
diff --git a/bundles/org.openhab.binding.webexteams/NOTICE b/bundles/org.openhab.binding.webexteams/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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 (file)
index 0000000..f3a1093
--- /dev/null
@@ -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 (file)
index 0000000..0f8d2d7
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>3.4.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.webexteams</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: WebexTeams Binding</name>
+
+</project>
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 (file)
index 0000000..2e9c37b
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.webexteams-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-binding-webexteams" description="WebexTeams Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.webexteams/${project.version}</bundle>
+       </feature>
+</features>
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 (file)
index 0000000..881b0f0
--- /dev/null
@@ -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<WebexTeamsHandler> 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<String, Object> 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<WebexTeamsHandler> 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<WebexTeamsHandler> 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 (file)
index 0000000..4ee3318
--- /dev/null
@@ -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 = "<p class='block'>Manually add a Webex Account to authorize it here.<p>";
+    private static final String HTML_USER_AUTHORIZED = "<div class='row authorized'>Account authorized for user %s.</div>";
+    private static final String HTML_ERROR = "<p class='block error'>Call to Webex failed with error: %s</p>";
+
+    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 = "<meta http-equiv='refresh' content='10; url=%s'>";
+    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<String, String> 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<String, String> 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<String> 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<WebexTeamsHandler> 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<String, String> 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<String, String> 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 (file)
index 0000000..2897ac8
--- /dev/null
@@ -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 (file)
index 0000000..071915a
--- /dev/null
@@ -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 (file)
index 0000000..985e523
--- /dev/null
@@ -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 (file)
index 0000000..a1cea47
--- /dev/null
@@ -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 (file)
index 0000000..fe3d2b4
--- /dev/null
@@ -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 (file)
index 0000000..d592225
--- /dev/null
@@ -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<Class<? extends ThingHandlerService>> 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 <code>true</code>, if sending the message has been successful and
+     *         <code>false</code> 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 <code>true</code>, if sending the message has been successful and
+     *         <code>false</code> 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 <code>true</code>, if sending the message has been successful and
+     *         <code>false</code> 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 <code>true</code>, if sending the message has been successful and
+     *         <code>false</code> 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 <code>true</code>, if sending the message has been successful and
+     *         <code>false</code> 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 <code>true</code>, if sending the message has been successful and
+     *         <code>false</code> 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 <code>Message</code>
+     * 
+     * @param msg the <code>Message</code> to be sent
+     * @return <code>true</code>, if sending the message has been successful and
+     *         <code>false</code> 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 (file)
index 0000000..a61ef52
--- /dev/null
@@ -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<ThingTypeUID> 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 (file)
index 0000000..3274104
--- /dev/null
@@ -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 <code>Message</code> 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 (file)
index 0000000..8fff760
--- /dev/null
@@ -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 <code>Person</code> 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 (file)
index 0000000..b239078
--- /dev/null
@@ -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 <code>Person</code> object for the account.
+     * 
+     * @return a <code>Person</code> 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 <I, O> O request(URI url, HttpMethod method, Class<O> 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 <I, O> O doRequest(URI url, HttpMethod method, String authToken, Class<O> 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 (file)
index 0000000..5099831
--- /dev/null
@@ -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 (file)
index 0000000..850d8a8
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<binding:binding id="webexteams" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
+
+       <name>WebexTeams Binding</name>
+       <description>This is the binding for WebexTeams. Send messages with actions.</description>
+
+</binding:binding>
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 (file)
index 0000000..ee0486a
--- /dev/null
@@ -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 (file)
index 0000000..7369090
--- /dev/null
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="webexteams"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <!-- Account Thing Type -->
+       <thing-type id="account">
+               <label>WebexTeams Account</label>
+               <description>WebexTeams account used to send messages.</description>
+
+               <channels>
+                       <channel id="status" typeId="status"/>
+                       <channel id="lastactivity" typeId="lastactivity"/>
+               </channels>
+
+               <properties>
+                       <property name="type"></property>
+                       <property name="name"></property>
+               </properties>
+
+               <config-description>
+                       <parameter name="token" type="text" required="false">
+                               <context>password</context>
+                               <label>Authorization Token</label>
+                               <description>Authorization token. Only use with a bot account.</description>
+                       </parameter>
+                       <parameter name="clientId" type="text" required="false">
+                               <label>Client Id</label>
+                               <description>Client Id. Only use with a person integration.</description>
+                       </parameter>
+                       <parameter name="clientSecret" type="text" required="false">
+                               <context>password</context>
+                               <label>Client Secret</label>
+                               <description>Client Secret. Only use with a person integration.</description>
+                       </parameter>
+                       <parameter name="refreshPeriod" type="integer" required="false">
+                               <label>Refresh Period (seconds)</label>
+                               <default>300</default>
+                               <description>Refresh period for channels. Low numbers increase accuracy, but could hit API rate limits. Defaults to
+                                       300 secs.</description>
+                       </parameter>
+                       <parameter name="roomId" type="text" required="false">
+                               <label>Default Room Id</label>
+                               <description>Id of the default room to send messages</description>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+       <!-- Botname Channel Type -->
+       <channel-type id="status">
+               <item-type>String</item-type>
+               <label>Status</label>
+               <description>The current presence status of the person</description>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="lastactivity">
+               <item-type>DateTime</item-type>
+               <label>Last Activity</label>
+               <description>The date and time of the person's last activity within Webex</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+</thing:thing-descriptions>
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 (file)
index 0000000..0a741c2
--- /dev/null
@@ -0,0 +1,8 @@
+<div class="row" id="${account.id}">
+  <div class="one column">${account.type}:</div>
+  <div class="nine columns"><i>${account.name}${account.user}</i></div>
+  <div class="two columns ${account.showbtn}">
+    <div class="button-primary"><a href=${account.authorize}>Authorize Account</a></div>
+  </div>
+  <div class="two columns ${account.showmsg}">${account.msg}</div>
+</div>
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 (file)
index 0000000..ddee78a
--- /dev/null
@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>        
+        <!-- Basic Page Needs
+        –––––––––––––––––––––––––––––––––––––––––––––––––– -->
+        <meta charset="utf-8">
+        <title>Authorize openHAB binding for Webex</title>
+        <meta name="description" content="">
+        <meta name="author" content="tom@ducbase.com">
+        ${pageRefresh}
+
+        <!-- Mobile Specific Metas
+        –––––––––––––––––––––––––––––––––––––––––––––––––– -->
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+
+        <!-- FONT
+        –––––––––––––––––––––––––––––––––––––––––––––––––– -->
+        <link href="//fonts.googleapis.com/css?family=Raleway:400,300,600" rel="stylesheet" type="text/css">
+
+        <!-- CSS
+        –––––––––––––––––––––––––––––––––––––––––––––––––– -->
+        <link rel="stylesheet" href="res/css/normalize.css">
+        <link rel="stylesheet" href="res/css/skeleton.css">
+        <link rel="stylesheet" href="res/css/custom.css">
+
+        <!-- Favicon
+        –––––––––––––––––––––––––––––––––––––––––––––––––– -->
+        <link rel="shortcut icon" href="res/images/favicon.ico">
+    </head>
+    <body>
+        <div class="container">
+            <div class="row bottom-one">
+                <h3>Authorize openHAB binding for Webex</h3>
+                <p>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.</p>
+                <p>To use this binding the following requirements apply:</p>
+                <ul>
+                    <li>A Cisco Webex account.</li>
+                    <li>Create an integration (a bot account doesn't require oAuth authorization)</li>
+                </ul>
+                <p>
+                    The redirect URI to use with the Webex API for this openHAB installation is
+                    <a href="${redirectUri}">${redirectUri}</a>
+                </p>
+        
+            </div>
+                
+            ${authorizedUser}
+            ${accounts}
+            
+            <div class="row">
+                ${error}
+            </div>
+    
+        </div>
+
+    </body>
+</html>
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 (file)
index 0000000..3bf627e
--- /dev/null
@@ -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 (file)
index 0000000..81c6f31
--- /dev/null
@@ -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 (file)
index 0000000..f28bf6c
--- /dev/null
@@ -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 (file)
index 0000000..ae86404
Binary files /dev/null and b/bundles/org.openhab.binding.webexteams/src/main/resources/web/images/favicon.ico differ
index 7249eb1ebcace200494d5b3accb19f240ee90178..8f0b5b7a8c82aa7d9950551c9e4d6aac88e59734 100644 (file)
     <module>org.openhab.binding.warmup</module>
     <module>org.openhab.binding.weathercompany</module>
     <module>org.openhab.binding.weatherunderground</module>
+    <module>org.openhab.binding.webexteams</module>
     <module>org.openhab.binding.webthing</module>
     <module>org.openhab.binding.wemo</module>
     <module>org.openhab.binding.wifiled</module>