├── .github └── dependabot.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── bruno └── HA │ ├── bruno.json │ ├── environments │ └── HA.bru │ ├── get api state.bru │ └── post api state.bru ├── build.rs ├── cliff.toml ├── microsoft-teams.ico ├── readme.md └── src ├── configuration.rs ├── home_assistant ├── api.rs ├── configuration.rs └── mod.rs ├── logging.rs ├── main.rs ├── mqtt ├── api.rs ├── configuration.rs └── mod.rs ├── mutex.rs ├── teams_log ├── file_locator.rs ├── file_notifier.rs ├── mod.rs ├── parser.rs ├── states.rs └── watcher.rs ├── teams_ws ├── api.rs ├── configuration.rs ├── mod.rs └── states.rs ├── traits.rs ├── tray └── mod.rs └── utils.rs /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .idea/ 4 | /output.log 5 | /conf.ini 6 | release-plz.exe 7 | /tests 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Teams Status - RS 2 | 3 | ## [1.2.0] - 2025-02-06 4 | 5 | ### 🚀 Features 6 | - Prevent opening of the same exe multiple times 7 | 8 | ### ⚙️ Miscellaneous Tasks 9 | - Update deps 10 | 11 | 12 | ## [1.1.0] - 2025-01-29 13 | 14 | ### 🐛 Bug Fixes 15 | - Ensure attributes are not converted to JSON when written 16 | - Fixed #30 17 | 18 | ### 🧪 Testing 19 | - Improve Bruno post call 20 | 21 | 22 | ## [1.0.2] - 2025-01-25 23 | 24 | ### 🚀 Features 25 | - (ha_api) Add support for custom attributes set in HA, the service will query the attributes once and store them so they are re-sent each time 26 | - Fixes #30 27 | 28 | ### 🚜 Refactor 29 | - Change in file encoding? 30 | 31 | ### 🎨 Styling 32 | - Minor reformatting 33 | 34 | ### 🧪 Testing 35 | - Add Bruno project to call HA API for testing 36 | 37 | ### ⚙️ Miscellaneous Tasks 38 | - Update deps 39 | - Create LICENSE 40 | - Update deps 41 | 42 | 43 | ## [1.0.1] - 2024-07-01 44 | 45 | ### 🐛 Bug Fixes 46 | - (ha_api) Prevent application crash if HA is not available 47 | - (ha_api) Prevent application crash if HA is not available 48 | 49 | ### ⚙️ Miscellaneous Tasks 50 | - Update deps 51 | - Move unsused line of code, for future use (maybe) 52 | - Update deps 53 | 54 | 55 | ## [1.0.0] - 2024-04-12 56 | 57 | ### ⚡ Performance 58 | - Concurrent HA API calls 59 | - Only update HA API for states that have changed 60 | 61 | ### ⚙️ Miscellaneous Tasks 62 | - Remove duplicates from CHANGELOG.md 63 | 64 | 65 | ## [0.5.0] - 2024-04-11 66 | 67 | ### 🐛 Bug Fixes 68 | - Prevent sending additional updates 69 | - Temporarily build with a local home-assistant-rest dep as it includes non-published fixes, fixes #21 70 | 71 | ### 📚 Documentation 72 | - Add HA persistent entities setup, fixes #15 73 | 74 | ### ⚡ Performance 75 | - Update is_in_meeting and is_video_on first as the HA calls can take some time and create delays in automations 76 | 77 | ### ⚙️ Miscellaneous Tasks 78 | - Add WIP Teams log parsing as it was discovered switching back to 'Available' is not being logged, rendering this whole mod useless until it is. Not compiled in project. 79 | - Remove unoptimized build settings 80 | - Update deps 81 | - Add logging 82 | 83 | 84 | ## [0.4.1] - 2024-03-27 85 | 86 | ### 🐛 Bug Fixes 87 | - Ensure MQTT is reconnected upon connection failure, fixes #19 88 | 89 | ### ⚙️ Miscellaneous Tasks 90 | - Convert from using release-plz to git-cliff as it fits my needs better for this type of application 91 | - Update dependencies 92 | 93 | 94 | ## [0.4.0] - 2024-03-13 95 | 96 | ### 🚀 Features 97 | - (mqtt) Allow use of 'mqtt://' prefix in URL, which will be removed and saved back to conf.ini 98 | - Log panics to facilitate locating run-time errors 99 | 100 | ### 🐛 Bug Fixes 101 | - (mqtt) Allow use of 'mqtt://' prefix in URL, which will be removed and saved back to conf.ini 102 | 103 | ### 🚜 Refactor 104 | - (teams_ws) Rename files to make way for log parser 105 | 106 | ### 🎨 Styling 107 | - Cleanup comment 108 | 109 | ### ⚙️ Miscellaneous Tasks 110 | - Remove unused CICD 111 | - Add release-plz configuration 112 | - Ignore exe 113 | - Initial changelog with releaze-plz 114 | - Update packages 115 | - Ignore tests folder 116 | - Update dependencies 117 | 118 | 119 | ## [0.3.0] - 2024-01-11 120 | 121 | ### 🚀 Features 122 | - Addition of new entities (all that are in the Teams API) for both HA and MQTT 123 | 124 | ### 🚜 Refactor 125 | - Fix warning 126 | - Addition of new entities (all that are in the Teams API) for both HA and MQTT 127 | 128 | ### ⚙️ Miscellaneous Tasks 129 | - Bump version to 0.3.0 130 | 131 | 132 | ## [0.2.3] - 2024-01-09 133 | 134 | ### 🚀 Features 135 | - Retain mqtt messages 136 | - Retain mqtt messages 137 | 138 | ### 🐛 Bug Fixes 139 | - Prevent application from crashing if Teams is closed while running 140 | 141 | ### 🚜 Refactor 142 | - Remove unused error unit 143 | 144 | ### ⚙️ Miscellaneous Tasks 145 | - Update dependencies 146 | - Increase versioning 147 | 148 | ### Signed-off-by 149 | - Dependabot[bot] 150 | 151 | 152 | ## [0.2.2] - 2023-11-20 153 | 154 | 155 | ## [0.2.1] - 2023-11-20 156 | 157 | 158 | ## [0.2.0] - 2023-11-19 159 | 160 | 161 | ## [0.1.0] - 2023-11-16 162 | 163 | ### 🐛 Bug Fixes 164 | - Fix wrong value used for video boolean, ensure there is an initial update when opening the app 165 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "teams_status" 3 | version = "1.2.0" 4 | edition = "2021" 5 | 6 | [package.metadata.winres] 7 | ProductName = "Teams Status" 8 | ProductVersion = "1.2.0" 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [dependencies] 13 | # temporary until the following crate is updated with the new fixes 14 | home-assistant-rest = { path = "./../home-assistant-rest" } 15 | log = "0.4.25" 16 | tokio-tungstenite = "0.26.1" 17 | json = "0.12.4" 18 | tokio = { version = "1.43.0", features = ["full"] } 19 | tray-item = "0.10.0" 20 | futures-util = "0.3.31" 21 | log4rs = "1.3.0" 22 | rust-ini = "0.21.0" 23 | magic-crypt = "4.0.1" 24 | rumqttc = "0.24.0" 25 | serde_json = "1.0.138" 26 | async-trait = "0.1.86" 27 | anyhow = "1.0.95" 28 | log-panics = { version = "2.1.0", features = ["with-backtrace"] } 29 | md-5 = "0.10.6" 30 | url = "2.5.4" 31 | # regex = "1.10.3" # for teams_log 32 | # notify = { version = "6.1.1" } # for teams_log 33 | 34 | [dependencies.windows] 35 | version = "0.59" 36 | features = [ 37 | "Win32_Security", 38 | "Win32_System_Threading", 39 | ] 40 | 41 | [build-dependencies] 42 | winres = "0.1.12" 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Antoine Gaudreau Simard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bruno/HA/bruno.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1", 3 | "name": "HA", 4 | "type": "collection", 5 | "ignore": [ 6 | "node_modules", 7 | ".git" 8 | ] 9 | } -------------------------------------------------------------------------------- /bruno/HA/environments/HA.bru: -------------------------------------------------------------------------------- 1 | vars:secret [ 2 | TOKEN 3 | ] 4 | -------------------------------------------------------------------------------- /bruno/HA/get api state.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get api state 3 | type: http 4 | seq: 3 5 | } 6 | 7 | get { 8 | url: https:/ha.antoinegs.stream/api/states/binary_sensor.teams_muted 9 | body: json 10 | auth: bearer 11 | } 12 | 13 | headers { 14 | Content-Type: application/json 15 | } 16 | 17 | auth:bearer { 18 | token: {{TOKEN}} 19 | } 20 | 21 | body:json { 22 | { 23 | 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /bruno/HA/post api state.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: post api state 3 | type: http 4 | seq: 2 5 | } 6 | 7 | post { 8 | url: https:/ha.antoinegs.stream/api/states/binary_sensor.teams_muted 9 | body: json 10 | auth: bearer 11 | } 12 | 13 | headers { 14 | Content-Type: application/json 15 | } 16 | 17 | auth:bearer { 18 | token: {{TOKEN}} 19 | } 20 | 21 | body:json { 22 | { 23 | "entity_id": "binary_sensor.teams_muted", 24 | "state": "off", 25 | "attributes": { 26 | "icon": "mdi:microphone-off", 27 | "friendly_name": "Teams Muted", 28 | "templates": { 29 | "icon_color": "if (state === 'on') return 'rgb(255, 0, 0)';" 30 | }, 31 | "random_attr": { 32 | "more_hierarchy": { 33 | "something": "value" 34 | } 35 | } 36 | }, 37 | "last_changed": "2025-01-28T18:25:04.248424+00:00", 38 | "last_reported": "2025-01-29T01:09:56.878845+00:00", 39 | "last_updated": "2025-01-29T01:09:56.878845+00:00", 40 | "context": { 41 | "id": "01JJQSQ5GE5M05C1PTKADZ7R3S", 42 | "parent_id": null, 43 | "user_id": "788487fb3ceb44aeb49182e8359deef1" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use winres::WindowsResource; 2 | 3 | fn main() { 4 | let mut res = WindowsResource::new(); 5 | res.set_icon_with_id("microsoft-teams.ico", "default-icon"); 6 | res.compile().unwrap(); 7 | } 8 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ default configuration file 2 | # https://git-cliff.org/docs/configuration 3 | # 4 | # Lines starting with "#" are comments. 5 | # Configuration options are organized into tables and keys. 6 | # See documentation for more information on available options. 7 | 8 | [changelog] 9 | # changelog header 10 | header = """ 11 | # Teams Status - RS\n 12 | """ 13 | # template for the changelog body 14 | # https://keats.github.io/tera/docs/#introduction 15 | body = """ 16 | {% if version %}\ 17 | ## [{{ version | trim_start_matches(pat="v") | trim_end_matches(pat="-not-optimized") | trim_end_matches(pat="-no-optimized") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 18 | {% else %}\ 19 | ## [unreleased] 20 | {% endif %}\ 21 | {% for group, commits in commits | group_by(attribute="group") %} 22 | ### {{ group | striptags | trim | upper_first }}\ 23 | {% for commit in commits %} 24 | - {% if commit.scope %}({{ commit.scope }}) {% endif %}\ 25 | {% if commit.breaking %}[**breaking**] {% endif %}\ 26 | {{ commit.message | upper_first }}\ 27 | {% endfor %} 28 | {% endfor %}\n\n 29 | """ 30 | # template for the changelog footer 31 | footer = """ 32 | 33 | """ 34 | # remove the leading and trailing s 35 | trim = true 36 | # postprocessors 37 | postprocessors = [ 38 | # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL 39 | ] 40 | 41 | [git] 42 | # parse the commits based on https://www.conventionalcommits.org 43 | conventional_commits = true 44 | # filter out the commits that are not conventional 45 | filter_unconventional = true 46 | # process each line of a commit as an individual commit 47 | split_commits = true 48 | # regex for preprocessing the commit messages 49 | commit_preprocessors = [ 50 | # Replace issue numbers 51 | #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, 52 | # Check spelling of the commit with https://github.com/crate-ci/typos 53 | # If the spelling is incorrect, it will be automatically fixed. 54 | #{ pattern = '.*', replace_command = 'typos --write-changes -' }, 55 | ] 56 | # regex for parsing and grouping commits 57 | commit_parsers = [ 58 | { message = "^feat", group = "🚀 Features" }, 59 | { message = "^fix", group = "🐛 Bug Fixes" }, 60 | { message = "^doc", group = "📚 Documentation" }, 61 | { message = "^perf", group = "⚡ Performance" }, 62 | { message = "^refactor", group = "🚜 Refactor" }, 63 | { message = "^style", group = "🎨 Styling" }, 64 | { message = "^test", group = "🧪 Testing" }, 65 | { message = "^chore\\(release\\):", skip = true }, 66 | { message = "^release:", skip = true }, 67 | { message = "^chore\\(deps.*\\)", skip = true }, 68 | { message = "^chore\\(pr\\)", skip = true }, 69 | { message = "^chore\\(pull\\)", skip = true }, 70 | { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" }, 71 | { body = ".*security", group = "🛡️ Security" }, 72 | { message = "^revert", group = "◀️ Revert" }, 73 | ] 74 | # protect breaking changes from being skipped due to matching a skipping commit_parser 75 | protect_breaking_commits = false 76 | # filter out the commits that are not matched by commit parsers 77 | filter_commits = false 78 | # regex for matching git tags 79 | # tag_pattern = "v[0-9].*" 80 | # regex for skipping tags 81 | # skip_tags = "" 82 | # regex for ignoring tags 83 | # ignore_tags = ".*-optimized" 84 | # sort the tags topologically 85 | topo_order = false 86 | # sort the commits inside sections by oldest/newest order 87 | sort_commits = "oldest" 88 | # limit the number of commits included in the changelog. 89 | # limit_commits = 42 90 | -------------------------------------------------------------------------------- /microsoft-teams.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AntoineGS/teams-status-rs/a026ef93b63f933266a6652c66e3fdba6b46448a/microsoft-teams.ico -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | - Download teams_status.exe from https://github.com/AntoineGS/teams-status-rs/releases to your Windows computer that 4 | runs the New Teams client 5 | - Launch the application, it will generate the ini file (conf.ini) in the same folder as the .exe 6 | - Use Windows Task Manager (Details tab) to end the 'teams_status.exe' process 7 | - In Microsoft Teams, enable the Third-Party 8 | API ([see Microsoft documentation](https://support.microsoft.com/en-us/office/connect-to-third-party-devices-in-microsoft-teams-aabca9f2-47bb-407f-9f9b-81a104a883d6?storagetype=live)) 9 | - The API Token will be generated automatically by the integration, so leave it blank in the configuration file 10 | - Decide on whether you will use MQTT or direct HA integration, setting the URL to the integration will activate it, but 11 | you can only use one or the other: 12 | - MQTT 13 | - Set the URL 14 | - Set the username and password if applicable 15 | - Double-check the other configurations, they have default values, but you may want/need to change them 16 | - HA (Home Assistant) 17 | - Set the URL 18 | Home Assistant URL usually has this format: http://Your_HomeAssistant_IP_Goes_Here:8123 19 | - In Home Assistant, generate a Long-Lived Access 20 | Token ([see HA documentation](https://developers.home-assistant.io/docs/auth_api/#long-lived-access-token)) 21 | - Paste it into the conf.ini 22 | - Double-check the other configurations, they have default values, but you may want/need to change them 23 | - (optional) Set the entities as persistent in HA, otherwise they will show up as missing if the application 24 | has been turned off for some time, see [here](#ha-persistent-entities). 25 | - Run the application again 26 | - It will create the entities in HA automatically when it connects 27 | - Start a meeting in Teams (you can be the only person in it) 28 | - From the 'Teams Status' tray icon, right-click, and click on `Toggle Mute` 29 | - You will get a prompt in Teams to allow the application to use the API 30 | - If you do not click on time Teams will close the prompt. Simply click on Toggle Mute again. 31 | - Optional: Configure to run automatically after Windows logon 32 | - Browse to 'teams_status.exe' using File Explorer, right click and choose Copy 33 | - Browse to "%AppData%\Microsoft\Windows\Start Menu\Programs\Startup" 34 | - Right-click in a blank area of the File Explorer window and choose Paste Shortcut 35 | 36 | # HA Persistent Entities 37 | 38 | For the entities to persist with the native HA integration, you will need to create the entities manually: 39 | 40 | - Warning! If you are already using the integration, make sure all entities are moved from HA and that 41 | the `teams-status` application is closed. Otherwise, it will duplicate sensors. 42 | - In HA's `configuration.yaml` file, under the following section (add it if you do not have it): 43 | 44 | ```yaml 45 | template: 46 | - binary_sensor: 47 | ``` 48 | 49 | ```yaml 50 | - name: "Teams Muted" 51 | unique_id: "ts_a7703e21-2ae1-4af5-ba77-108f2462004a" 52 | icon: "mdi:microphone-off" 53 | state: "{{ None }}" 54 | - name: "Teams Video" 55 | unique_id: "ts_38dc82bf-bc6d-491f-84c1-9fbee02641a9" 56 | icon: "mdi:webcam-off" 57 | state: "{{ None }}" 58 | - name: "Teams Hand Raised" 59 | unique_id: "ts_9e7c62d5-5640-4cfb-8ff7-4eac9922030e" 60 | icon: "mdi:hand-back-left-off" 61 | state: "{{ None }}" 62 | - name: "Teams Meeting" 63 | unique_id: "ts_74837ead-9946-49c9-8aec-f25c0c031ec5" 64 | icon: "mdi:phone-off" 65 | state: "{{ None }}" 66 | - name: "Teams Recording" 67 | unique_id: "ts_493dcc2e-7cf6-456a-95b2-8cd029b2300c" 68 | icon: "mdi:power-off" 69 | state: "{{ None }}" 70 | - name: "Teams Background Blurred" 71 | unique_id: "ts_ae97f0dd-7dc3-4f9b-bfb4-ecbc30b8957b" 72 | icon: "mdi:blur-off" 73 | state: "{{ None }}" 74 | - name: "Teams Sharing" 75 | unique_id: "ts_402f1b21-5ad5-49d2-b451-2cf1e95cab65" 76 | icon: "mdi:projector-screen-off" 77 | state: "{{ None }}" 78 | - name: "Teams Unread Messages" 79 | unique_id: "ts_61500ecd-5f28-4be4-912d-a64f306fa0cc" 80 | icon: "mdi:message-off" 81 | state: "{{ None }}" 82 | ``` 83 | 84 | - The `name` and `friendly_name` should match what you have in the config file 85 | - The `unique_id` can be any unique identifier 86 | 87 | # Notices 88 | 89 | - Pull Requests, Issues, Feature Requests are all welcomed 90 | - This integration only supports the New Teams (2.0 client) 91 | - Logging is done in output.log, and rolls over at 10mb, keeping a maximum of two files 92 | - Passwords and keys are encrypted 93 | - This project utilizes the local Teams Client API (instead of Azure / M365) 94 | - Advantages 95 | - No permissions or App Registrations required in Azure, unlike several others that may require organizations to 96 | approve an exception depending on current security settings. 97 | - Disadvantages 98 | - If you join meetings from another client (mobile, web, etc.), this project will not see those status updates. 99 | - This project does not provide a general "Presence" color sensor 100 | 101 | # Example Data 102 | 103 | ### API Connection Prior to Getting Token 104 | 105 | ``` 106 | ws://localhost:8124?protocol-version=2.0.0&manufacturer=AntoineGS&device=HomeAssistant&app=MS-Teams-Websocket&app-version=1.0 107 | ``` 108 | 109 | ### API Connection With Token 110 | 111 | ``` 112 | ws://localhost:8124?token=FDUHINFHUSIDHNFSDFUIDSFHNUDSI&protocol-version=2.0.0&manufacturer=AntoineGS&device=HomeAssistant&app=MS-Teams-Websocket&app-version=1.0 113 | ``` 114 | 115 | ### Teams -> Client Update 116 | 117 | ```json 118 | { 119 | "meetingUpdate": { 120 | "meetingState": { 121 | "isMuted": false, 122 | "isVideoOn": false, 123 | "isHandRaised": false, 124 | "isInMeeting": true, 125 | "isRecordingOn": false, 126 | "isBackgroundBlurred": false, 127 | "isSharing": false, 128 | "hasUnreadMessages": false 129 | }, 130 | "meetingPermissions": { 131 | "canToggleMute": true, 132 | "canToggleVideo": true, 133 | "canToggleHand": true, 134 | "canToggleBlur": false, 135 | "canLeave": true, 136 | "canReact": true, 137 | "canToggleShareTray": true, 138 | "canToggleChat": true, 139 | "canStopSharing": false, 140 | "canPair": false 141 | } 142 | } 143 | } 144 | ``` 145 | 146 | ### Client -> Teams Request Toggle Mute 147 | 148 | ```json 149 | { 150 | "requestId": 1, 151 | "apiVersion": "2.0.0", 152 | "action": "toggle-mute" 153 | } 154 | ``` 155 | 156 | ### Teams -> Client Request Confirmation 157 | 158 | ```json 159 | { 160 | "requestId": 2, 161 | "response": "Success" 162 | } 163 | ``` 164 | 165 | ### Client -> Teams Token Refresh 166 | 167 | ```json 168 | { 169 | "tokenRefresh": "529547bd-9f11-4a83-9204-0e655b00fd5e" 170 | } 171 | ``` 172 | 173 | ### MQTT 174 | 175 | ```json 176 | { 177 | "in_meeting": "on", 178 | "video_on": "off" 179 | } 180 | ``` 181 | 182 | ### Reference Document (for legacy Teams) 183 | 184 | https://lostdomain.notion.site/Microsoft-Teams-WebSocket-API-5c042838bc3e4731bdfe679e864ab52a 185 | -------------------------------------------------------------------------------- /src/configuration.rs: -------------------------------------------------------------------------------- 1 | use crate::home_assistant::configuration::{ 2 | create_ha_configuration, HaConfiguration, HaEntity, HA_BACKGROUND_BLURRED, HA_FRIENDLY_NAME, 3 | HA_HAND_RAISED, HA_ICON_OFF, HA_ICON_ON, HA_ID, HA_IN_A_MEETING, HA_LONG_LIVE_TOKEN, HA_MUTED, 4 | HA_RECORDING, HA_SHARING, HA_UNREAD_MESSAGES, HA_URL, HA_VIDEO_ON, HOME_ASSISTANT, 5 | }; 6 | use crate::mqtt::configuration::{ 7 | create_mqtt_configuration, MqttConfiguration, MQTT, MQTT_BACKGROUND_BLURRED, MQTT_ENTITIES, 8 | MQTT_HAND_RAISED, MQTT_MEETING, MQTT_MUTED, MQTT_PASSWORD, MQTT_PORT, MQTT_PORT_DEFAULT, 9 | MQTT_RECORDING, MQTT_SHARING, MQTT_TOPIC, MQTT_UNREAD_MESSAGES, MQTT_URL, MQTT_USERNAME, 10 | MQTT_VIDEO, 11 | }; 12 | use crate::teams_ws::configuration::{ 13 | create_teams_configuration, TeamsConfiguration, TEAMS, TEAMS_API_TOKEN, TEAMS_URL, 14 | }; 15 | use crate::utils::{decrypt_if_needed, encrypt}; 16 | use ini::Ini; 17 | use log::{error, info}; 18 | use std::fs; 19 | 20 | const GENERAL: &str = "General"; 21 | const GEN_CONF_VERSION: &str = "Configuration Version"; 22 | const GEN_CONF_VERSION_CURRENT: u32 = 1; 23 | // Anything below this will result in copying the configuration as a backup as there are breaking changes 24 | const GEN_CONF_VERSION_CUTOFF: u32 = 1; 25 | const INI_FILE_NAME: &str = "conf.ini"; 26 | 27 | pub struct Configuration { 28 | pub ha: HaConfiguration, 29 | pub teams: TeamsConfiguration, 30 | pub mqtt: MqttConfiguration, 31 | pub version: u32, 32 | } 33 | 34 | pub fn get_configuration(save_configuration: bool) -> Configuration { 35 | let mut conf = create_configuration(); 36 | load_configuration(&mut conf); 37 | // We recreate the file in case we introduce new values or configs 38 | if save_configuration { 39 | save_ha_configuration(&conf); 40 | }; 41 | conf 42 | } 43 | 44 | fn load_entity(ha_entity: &mut HaEntity, config_name: &str, config_value: String) { 45 | match config_name { 46 | HA_ID => ha_entity.id = config_value, 47 | HA_FRIENDLY_NAME => ha_entity.friendly_name = config_value, 48 | HA_ICON_ON => ha_entity.icons.on = config_value, 49 | HA_ICON_OFF => ha_entity.icons.off = config_value, 50 | _ => { /* We just ignore incorrect configs */ } 51 | } 52 | } 53 | 54 | fn load_configuration(conf: &mut Configuration) { 55 | let i = Ini::load_from_file(INI_FILE_NAME).unwrap_or_else(|err| { 56 | info!( 57 | "The file conf.ini could not be loaded, we will create a new one: {}", 58 | err.to_string() 59 | ); 60 | return Ini::new(); 61 | }); 62 | 63 | for (sec, prop) in i.iter() { 64 | for (k, v) in prop.iter() { 65 | if v.is_empty() { 66 | continue; 67 | } 68 | 69 | let v_string = v.to_string(); 70 | 71 | match sec { 72 | Some(GENERAL) => match k { 73 | GEN_CONF_VERSION => conf.version = v.parse::().unwrap_or(0), 74 | &_ => {} 75 | }, 76 | Some(HOME_ASSISTANT) => match k { 77 | HA_LONG_LIVE_TOKEN => conf.ha.long_live_token = decrypt_if_needed(v), 78 | HA_URL => conf.ha.url = v.to_string(), 79 | _ => { /* We just ignore incorrect configs */ } 80 | }, 81 | Some(HA_MUTED) => load_entity(&mut conf.ha.entities.is_muted, k, v_string), 82 | Some(HA_VIDEO_ON) => load_entity(&mut conf.ha.entities.is_video_on, k, v_string), 83 | Some(HA_HAND_RAISED) => { 84 | load_entity(&mut conf.ha.entities.is_hand_raised, k, v_string) 85 | } 86 | Some(HA_IN_A_MEETING) => { 87 | load_entity(&mut conf.ha.entities.is_in_meeting, k, v_string) 88 | } 89 | Some(HA_RECORDING) => { 90 | load_entity(&mut conf.ha.entities.is_recording_on, k, v_string) 91 | } 92 | Some(HA_BACKGROUND_BLURRED) => { 93 | load_entity(&mut conf.ha.entities.is_background_blurred, k, v_string) 94 | } 95 | Some(HA_SHARING) => load_entity(&mut conf.ha.entities.is_sharing, k, v_string), 96 | Some(HA_UNREAD_MESSAGES) => { 97 | load_entity(&mut conf.ha.entities.has_unread_messages, k, v_string) 98 | } 99 | Some(TEAMS) => match k { 100 | TEAMS_URL => conf.teams.url = v.to_string(), 101 | TEAMS_API_TOKEN => conf.teams.api_token = decrypt_if_needed(v), 102 | _ => { /* We just ignore incorrect configs */ } 103 | }, 104 | Some(MQTT) => match k { 105 | MQTT_URL => conf.mqtt.set_url(v.to_string()), 106 | MQTT_PORT => conf.mqtt.port = v.parse().unwrap_or(MQTT_PORT_DEFAULT), 107 | MQTT_TOPIC => conf.mqtt.topic = v.to_string(), 108 | MQTT_USERNAME => conf.mqtt.username = v.to_string(), 109 | MQTT_PASSWORD => conf.mqtt.password = decrypt_if_needed(v), 110 | _ => { /* We just ignore incorrect configs */ } 111 | }, 112 | Some(MQTT_ENTITIES) => match k { 113 | MQTT_MUTED => conf.mqtt.mqtt_entities.muted = v.to_string(), 114 | MQTT_VIDEO => conf.mqtt.mqtt_entities.video = v.to_string(), 115 | MQTT_HAND_RAISED => conf.mqtt.mqtt_entities.hand_raised = v.to_string(), 116 | MQTT_MEETING => conf.mqtt.mqtt_entities.meeting = v.to_string(), 117 | MQTT_RECORDING => conf.mqtt.mqtt_entities.recording = v.to_string(), 118 | MQTT_BACKGROUND_BLURRED => { 119 | conf.mqtt.mqtt_entities.background_blurred = v.to_string() 120 | } 121 | MQTT_SHARING => conf.mqtt.mqtt_entities.sharing = v.to_string(), 122 | MQTT_UNREAD_MESSAGES => conf.mqtt.mqtt_entities.unread_messages = v.to_string(), 123 | _ => { /* We just ignore incorrect configs */ } 124 | }, 125 | _ => { /* We just ignore incorrect configs */ } 126 | } 127 | } 128 | } 129 | 130 | if conf.version < GEN_CONF_VERSION_CUTOFF { 131 | fs::copy( 132 | INI_FILE_NAME, 133 | format!("{}_backup_v{}.ini", INI_FILE_NAME, conf.version), 134 | ) 135 | .unwrap_or_else(|err| { 136 | error!( 137 | "Unable to back up original configuration: {}", 138 | err.to_string() 139 | ); 140 | 0 141 | }); 142 | } 143 | } 144 | 145 | fn create_configuration() -> Configuration { 146 | Configuration { 147 | ha: create_ha_configuration(), 148 | teams: create_teams_configuration(), 149 | mqtt: create_mqtt_configuration(), 150 | version: 0, 151 | } 152 | } 153 | 154 | fn add_entity(ini: &mut Ini, section: &str, ha_entity: &HaEntity) { 155 | ini.with_section(Some(section)) 156 | .set(HA_ID, &ha_entity.id) 157 | .set(HA_FRIENDLY_NAME, &ha_entity.friendly_name) 158 | .set(HA_ICON_ON, &ha_entity.icons.on) 159 | .set(HA_ICON_OFF, &ha_entity.icons.off); 160 | } 161 | fn save_ha_configuration(conf: &Configuration) { 162 | let mut ini = Ini::new(); 163 | ini.with_section(Some(TEAMS)) 164 | .set(TEAMS_URL, &conf.teams.url) 165 | .set(TEAMS_API_TOKEN, encrypt(&conf.teams.api_token)); 166 | 167 | ini.with_section(Some(HOME_ASSISTANT)) 168 | .set(HA_URL, &conf.ha.url) 169 | .set(HA_LONG_LIVE_TOKEN, encrypt(&conf.ha.long_live_token)); 170 | 171 | let ha_entities = &conf.ha.entities; 172 | add_entity(&mut ini, HA_MUTED, &ha_entities.is_muted); 173 | add_entity(&mut ini, HA_VIDEO_ON, &ha_entities.is_video_on); 174 | add_entity(&mut ini, HA_HAND_RAISED, &ha_entities.is_hand_raised); 175 | add_entity(&mut ini, HA_IN_A_MEETING, &ha_entities.is_in_meeting); 176 | add_entity(&mut ini, HA_RECORDING, &ha_entities.is_recording_on); 177 | add_entity( 178 | &mut ini, 179 | HA_BACKGROUND_BLURRED, 180 | &ha_entities.is_background_blurred, 181 | ); 182 | add_entity(&mut ini, HA_SHARING, &ha_entities.is_sharing); 183 | add_entity( 184 | &mut ini, 185 | HA_UNREAD_MESSAGES, 186 | &ha_entities.has_unread_messages, 187 | ); 188 | 189 | let mqtt = &conf.mqtt; 190 | ini.with_section(Some(MQTT)) 191 | .set(MQTT_URL, mqtt.url()) 192 | .set(MQTT_PORT, &mqtt.port.to_string()) 193 | .set(MQTT_TOPIC, &mqtt.topic) 194 | .set(MQTT_USERNAME, &mqtt.username) 195 | .set(MQTT_PASSWORD, encrypt(&mqtt.password)); 196 | 197 | let mqtt_entities = &conf.mqtt.mqtt_entities; 198 | ini.with_section(Some(MQTT_ENTITIES)) 199 | .set(MQTT_MUTED, &mqtt_entities.muted) 200 | .set(MQTT_VIDEO, &mqtt_entities.video) 201 | .set(MQTT_HAND_RAISED, &mqtt_entities.hand_raised) 202 | .set(MQTT_MEETING, &mqtt_entities.meeting) 203 | .set(MQTT_RECORDING, &mqtt_entities.recording) 204 | .set(MQTT_BACKGROUND_BLURRED, &mqtt_entities.background_blurred) 205 | .set(MQTT_SHARING, &mqtt_entities.sharing) 206 | .set(MQTT_UNREAD_MESSAGES, &mqtt_entities.unread_messages); 207 | ini.with_section(Some(GENERAL)) 208 | .set(GEN_CONF_VERSION, GEN_CONF_VERSION_CURRENT.to_string()); 209 | ini.write_to_file(INI_FILE_NAME).unwrap(); 210 | } 211 | -------------------------------------------------------------------------------- /src/home_assistant/api.rs: -------------------------------------------------------------------------------- 1 | use crate::home_assistant::configuration::{HaConfiguration, HaEntity}; 2 | use crate::teams_ws::states::TeamsStates; 3 | use crate::traits::Listener; 4 | use crate::utils::bool_to_str; 5 | use anyhow::anyhow; 6 | use async_trait::async_trait; 7 | use futures_util::future::try_join_all; 8 | use home_assistant_rest::post::StateParams; 9 | use home_assistant_rest::Client; 10 | use log::{error, info}; 11 | use serde_json::json; 12 | use std::collections::HashMap; 13 | use std::sync::atomic::{AtomicBool, Ordering}; 14 | 15 | pub struct HaApi { 16 | ha_configuration: HaConfiguration, 17 | queried_attributes: bool, 18 | } 19 | 20 | impl HaApi { 21 | pub fn new(ha_configuration: HaConfiguration) -> anyhow::Result { 22 | Ok(Self { 23 | ha_configuration, 24 | queried_attributes: false, 25 | }) 26 | } 27 | 28 | // fetch state of all entities used in the application, remove attributes that we are handling here, and save the rest to the entity struct 29 | async fn update_ha_entities_with_attributes(&mut self) -> anyhow::Result<()> { 30 | if self.queried_attributes { 31 | return Ok(()); 32 | } 33 | 34 | let client = Client::new( 35 | &self.ha_configuration.url, 36 | &self.ha_configuration.long_live_token, 37 | )?; 38 | let api_status = client.get_api_status().await; 39 | 40 | if api_status.is_err() || api_status?.message != "API running." { 41 | error!("Home Assistant API cannot be reached"); 42 | return Err(anyhow!("Home Assistant API cannot be reached")); 43 | } 44 | 45 | for (entity_id, entity) in self.ha_configuration.entities.iter_mut() { 46 | let state = client.get_states_of_entity(&entity_id).await; 47 | 48 | if state.is_err() { 49 | error!("Error fetching entity state: {}", state.unwrap_err()); 50 | continue; 51 | } 52 | 53 | for (key, value) in state?.attributes { 54 | if key != "friendly_name" && key != "icon" { 55 | info!( 56 | "Adding attribute '{}' of value '{}' to entity '{}'", 57 | &key, &value, entity_id 58 | ); 59 | // the following will convert non-string values to string unfortunately 60 | entity.additional_attributes.insert(key, value.clone()); 61 | } 62 | } 63 | } 64 | 65 | self.queried_attributes = true; 66 | Ok(()) 67 | } 68 | 69 | // friendly_name is needed as API calls wipe the configured name 70 | async fn update_ha( 71 | &self, 72 | state: &AtomicBool, 73 | prev_state: &AtomicBool, 74 | ha_entity: &HaEntity, 75 | force_update: bool, 76 | ) -> anyhow::Result<()> { 77 | let state_bool = state.load(Ordering::Relaxed); 78 | let prev_state_bool = prev_state.load(Ordering::Relaxed); 79 | 80 | // we exit early if nothing has changed, and we are not forcing an update 81 | if state_bool == prev_state_bool && !force_update { 82 | return Ok(()); 83 | } 84 | 85 | let client = Client::new( 86 | &self.ha_configuration.url, 87 | &self.ha_configuration.long_live_token, 88 | )?; 89 | // use this code to connect and get attributes upon starting the application 90 | let api_status = client.get_api_status().await; 91 | 92 | if api_status.is_err() || api_status?.message != "API running." { 93 | error!("Home Assistant API cannot be reached"); 94 | return Err(anyhow!("Home Assistant API cannot be reached")); 95 | } 96 | 97 | let mut attributes: HashMap = HashMap::new(); 98 | attributes.insert( 99 | "friendly_name".to_string(), 100 | json!(ha_entity.friendly_name.to_string()), 101 | ); 102 | 103 | let icon = if state_bool { 104 | &ha_entity.icons.on 105 | } else { 106 | &ha_entity.icons.off 107 | }; 108 | 109 | attributes.insert("icon".to_string(), json!(icon.to_string())); 110 | 111 | for (key, value) in &ha_entity.additional_attributes { 112 | attributes.insert(key.to_string(), value.clone()); 113 | } 114 | 115 | let state_str = bool_to_str(state_bool); 116 | let params = StateParams { 117 | entity_id: ha_entity.id.to_string(), 118 | state: state_str.clone(), 119 | attributes, 120 | }; 121 | 122 | info!("Updating HA entity ({}) to '{}'", &ha_entity.id, &state_str); 123 | 124 | let post_states_res = client.post_states(params).await; 125 | 126 | if post_states_res.is_err() { 127 | error!("{}", post_states_res.unwrap_err()); 128 | }; 129 | 130 | prev_state.store(state_bool, Ordering::Relaxed); 131 | 132 | Ok(()) 133 | } 134 | } 135 | 136 | #[async_trait] 137 | impl Listener for HaApi { 138 | async fn notify_changed( 139 | &mut self, 140 | teams_states: &TeamsStates, 141 | force_update: bool, 142 | ) -> anyhow::Result<()> { 143 | self.update_ha_entities_with_attributes().await?; 144 | 145 | // Reflection would be nice here... Tried with bevy_reflect but ran into an issue with AtomicBool 146 | let mut futures = Vec::new(); 147 | // let is_in_meeting = self.ha_configuration.entities.is_in_meeting.clone(); 148 | 149 | futures.push(self.update_ha( 150 | &teams_states.is_in_meeting, 151 | &teams_states.prev_is_in_meeting, 152 | &self.ha_configuration.entities.is_in_meeting, 153 | force_update, 154 | )); 155 | 156 | futures.push(self.update_ha( 157 | &teams_states.is_video_on, 158 | &teams_states.prev_is_video_on, 159 | &self.ha_configuration.entities.is_video_on, 160 | force_update, 161 | )); 162 | 163 | futures.push(self.update_ha( 164 | &teams_states.is_muted, 165 | &teams_states.prev_is_muted, 166 | &self.ha_configuration.entities.is_muted, 167 | force_update, 168 | )); 169 | 170 | futures.push(self.update_ha( 171 | &teams_states.is_hand_raised, 172 | &teams_states.prev_is_hand_raised, 173 | &self.ha_configuration.entities.is_hand_raised, 174 | force_update, 175 | )); 176 | 177 | futures.push(self.update_ha( 178 | &teams_states.is_recording_on, 179 | &teams_states.prev_is_recording_on, 180 | &self.ha_configuration.entities.is_recording_on, 181 | force_update, 182 | )); 183 | 184 | futures.push(self.update_ha( 185 | &teams_states.is_background_blurred, 186 | &teams_states.prev_is_background_blurred, 187 | &self.ha_configuration.entities.is_background_blurred, 188 | force_update, 189 | )); 190 | 191 | futures.push(self.update_ha( 192 | &teams_states.is_sharing, 193 | &teams_states.prev_is_sharing, 194 | &self.ha_configuration.entities.is_sharing, 195 | force_update, 196 | )); 197 | 198 | futures.push(self.update_ha( 199 | &teams_states.has_unread_messages, 200 | &teams_states.prev_has_unread_messages, 201 | &self.ha_configuration.entities.has_unread_messages, 202 | force_update, 203 | )); 204 | 205 | try_join_all(futures).await?; 206 | 207 | Ok(()) 208 | } 209 | 210 | fn reconnect(&mut self) { 211 | // considered not needed for now, as I believe the API will reconnect upon failure (not tested) 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/home_assistant/configuration.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | pub const HOME_ASSISTANT: &str = "Home Assistant"; 4 | pub const HA_LONG_LIVE_TOKEN: &str = "Long Live Token"; 5 | pub const HA_URL: &str = "URL"; 6 | pub const HA_MUTED: &str = "Home Assistant Entity - Muted"; 7 | pub const HA_VIDEO_ON: &str = "Home Assistant Entity - Video On"; 8 | pub const HA_HAND_RAISED: &str = "Home Assistant Entity - Hand Raised"; 9 | pub const HA_IN_A_MEETING: &str = "Home Assistant Entity - In a Meeting"; 10 | pub const HA_RECORDING: &str = "Home Assistant Entity - Recording"; 11 | pub const HA_BACKGROUND_BLURRED: &str = "Home Assistant Entity - Background Blurred"; 12 | pub const HA_SHARING: &str = "Home Assistant Entity - Sharing"; 13 | pub const HA_UNREAD_MESSAGES: &str = "Home Assistant Entity - Unread Messages"; 14 | pub const HA_ID: &str = "ID"; 15 | pub const HA_FRIENDLY_NAME: &str = "Friendly Name"; 16 | pub const HA_ICON_ON: &str = "Icon On"; 17 | pub const HA_ICON_OFF: &str = "Icon Off"; 18 | 19 | #[derive(Clone)] 20 | pub struct HaIcons { 21 | pub on: String, 22 | pub off: String, 23 | } 24 | 25 | #[derive(Clone)] 26 | pub struct HaEntity { 27 | pub id: String, 28 | pub friendly_name: String, 29 | pub icons: HaIcons, 30 | pub additional_attributes: HashMap, 31 | } 32 | 33 | #[derive(Clone)] 34 | pub struct HaEntities { 35 | pub is_muted: HaEntity, 36 | pub is_video_on: HaEntity, 37 | pub is_hand_raised: HaEntity, 38 | pub is_in_meeting: HaEntity, 39 | pub is_recording_on: HaEntity, 40 | pub is_background_blurred: HaEntity, 41 | pub is_sharing: HaEntity, 42 | pub has_unread_messages: HaEntity, 43 | } 44 | 45 | impl IntoIterator for HaEntities { 46 | type Item = (String, HaEntity); 47 | type IntoIter = std::vec::IntoIter; 48 | 49 | fn into_iter(self) -> Self::IntoIter { 50 | let mut entities = Vec::new(); 51 | entities.push((self.is_muted.id.to_string(), self.is_muted)); 52 | entities.push((self.is_video_on.id.to_string(), self.is_video_on)); 53 | entities.push((self.is_hand_raised.id.to_string(), self.is_hand_raised)); 54 | entities.push((self.is_in_meeting.id.to_string(), self.is_in_meeting)); 55 | entities.push((self.is_recording_on.id.to_string(), self.is_recording_on)); 56 | entities.push(( 57 | self.is_background_blurred.id.to_string(), 58 | self.is_background_blurred, 59 | )); 60 | entities.push((self.is_sharing.id.to_string(), self.is_sharing)); 61 | entities.push(( 62 | self.has_unread_messages.id.to_string(), 63 | self.has_unread_messages, 64 | )); 65 | entities.into_iter() 66 | } 67 | } 68 | 69 | impl HaEntities { 70 | pub fn iter_mut(&mut self) -> impl Iterator { 71 | let mut entities = Vec::new(); 72 | entities.push((self.is_muted.id.clone(), &mut self.is_muted)); 73 | entities.push((self.is_video_on.id.clone(), &mut self.is_video_on)); 74 | entities.push((self.is_hand_raised.id.clone(), &mut self.is_hand_raised)); 75 | entities.push((self.is_in_meeting.id.clone(), &mut self.is_in_meeting)); 76 | entities.push((self.is_recording_on.id.clone(), &mut self.is_recording_on)); 77 | entities.push(( 78 | self.is_background_blurred.id.clone(), 79 | &mut self.is_background_blurred, 80 | )); 81 | entities.push((self.is_sharing.id.clone(), &mut self.is_sharing)); 82 | entities.push(( 83 | self.has_unread_messages.id.clone(), 84 | &mut self.has_unread_messages, 85 | )); 86 | entities.into_iter() 87 | } 88 | } 89 | 90 | pub struct HaConfiguration { 91 | pub long_live_token: String, 92 | pub url: String, 93 | pub entities: HaEntities, 94 | } 95 | 96 | pub fn create_ha_configuration() -> HaConfiguration { 97 | let ha_entities = HaEntities { 98 | is_muted: HaEntity { 99 | id: "binary_sensor.teams_muted".to_string(), 100 | friendly_name: "Teams Muted".to_string(), 101 | icons: HaIcons { 102 | on: "mdi:microphone".to_string(), 103 | off: "mdi:microphone-off".to_string(), 104 | }, 105 | additional_attributes: HashMap::new(), 106 | }, 107 | 108 | is_video_on: HaEntity { 109 | id: "binary_sensor.teams_video".to_string(), 110 | friendly_name: "Teams Video".to_string(), 111 | icons: HaIcons { 112 | on: "mdi:webcam".to_string(), 113 | off: "mdi:webcam-off".to_string(), 114 | }, 115 | additional_attributes: HashMap::new(), 116 | }, 117 | 118 | is_hand_raised: HaEntity { 119 | id: "binary_sensor.teams_hand_raised".to_string(), 120 | friendly_name: "Teams Hand Raised".to_string(), 121 | icons: HaIcons { 122 | on: "mdi:hand-back-left".to_string(), 123 | off: "mdi:hand-back-left-off".to_string(), 124 | }, 125 | additional_attributes: HashMap::new(), 126 | }, 127 | 128 | is_in_meeting: HaEntity { 129 | id: "binary_sensor.teams_meeting".to_string(), 130 | friendly_name: "Teams Meeting".to_string(), 131 | icons: HaIcons { 132 | on: "mdi:phone-in-talk".to_string(), 133 | off: "mdi:phone-off".to_string(), 134 | }, 135 | additional_attributes: HashMap::new(), 136 | }, 137 | 138 | is_recording_on: HaEntity { 139 | id: "binary_sensor.teams_recording".to_string(), 140 | friendly_name: "Teams Recording".to_string(), 141 | icons: HaIcons { 142 | on: "mdi:record-rec".to_string(), 143 | off: "mdi:power-off".to_string(), 144 | }, 145 | additional_attributes: HashMap::new(), 146 | }, 147 | 148 | is_background_blurred: HaEntity { 149 | id: "binary_sensor.teams_background_blurred".to_string(), 150 | friendly_name: "Teams Background Blurred".to_string(), 151 | icons: HaIcons { 152 | on: "mdi:blur".to_string(), 153 | off: "mdi:blur-off".to_string(), 154 | }, 155 | additional_attributes: HashMap::new(), 156 | }, 157 | 158 | is_sharing: HaEntity { 159 | id: "binary_sensor.teams_sharing".to_string(), 160 | friendly_name: "Teams Sharing".to_string(), 161 | icons: HaIcons { 162 | on: "mdi:projector-screen".to_string(), 163 | off: "mdi:projector-screen-off".to_string(), 164 | }, 165 | additional_attributes: HashMap::new(), 166 | }, 167 | 168 | has_unread_messages: HaEntity { 169 | id: "binary_sensor.teams_unread_messages".to_string(), 170 | friendly_name: "Teams Unread Messages".to_string(), 171 | icons: HaIcons { 172 | on: "mdi:message-alert".to_string(), 173 | off: "mdi:message-off".to_string(), 174 | }, 175 | additional_attributes: HashMap::new(), 176 | }, 177 | }; 178 | 179 | HaConfiguration { 180 | long_live_token: "".to_string(), 181 | url: "".to_string(), 182 | entities: ha_entities, 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/home_assistant/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | pub mod configuration; 3 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | use log::LevelFilter; 2 | use log4rs::append::rolling_file::policy::compound::roll::fixed_window::FixedWindowRoller; 3 | use log4rs::append::rolling_file::policy::compound::trigger::size::SizeTrigger; 4 | use log4rs::append::rolling_file::policy::compound::CompoundPolicy; 5 | use log4rs::append::rolling_file::RollingFileAppender; 6 | use log4rs::config::{Appender, Root}; 7 | use log4rs::encode::pattern::PatternEncoder; 8 | use log4rs::Config; 9 | 10 | pub fn initialize_logging() { 11 | let fixed_window_roller = FixedWindowRoller::builder() 12 | .build("output_old{}.log", 1) 13 | .unwrap(); 14 | let size_limit = 10 * 1024 * 1024; 15 | let size_trigger = SizeTrigger::new(size_limit); 16 | let compound_policy = 17 | CompoundPolicy::new(Box::new(size_trigger), Box::new(fixed_window_roller)); 18 | 19 | let logfile = RollingFileAppender::builder() 20 | .encoder(Box::new(PatternEncoder::new("{d:<36} {l} {t} - {m}{n}"))) 21 | .build("output.log", Box::new(compound_policy)) 22 | .unwrap(); 23 | 24 | let log_config = Config::builder() 25 | .appender(Appender::builder().build("logfile", Box::new(logfile))) 26 | .build(Root::builder().appender("logfile").build(LevelFilter::Info)) 27 | .unwrap(); 28 | 29 | log4rs::init_config(log_config).unwrap(); 30 | log_panics::init(); 31 | } 32 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![windows_subsystem = "windows"] 2 | 3 | mod configuration; 4 | mod home_assistant; 5 | mod logging; 6 | mod mqtt; 7 | mod mutex; 8 | mod teams_ws; 9 | mod traits; 10 | mod tray; 11 | mod utils; 12 | 13 | use mutex::{create_mutex, release_mutex}; 14 | use std::process::exit; 15 | use std::sync::atomic::{AtomicBool, Ordering}; 16 | use std::sync::Arc; 17 | use std::time; 18 | use tokio::sync::Mutex; 19 | 20 | use crate::configuration::get_configuration; 21 | use crate::logging::initialize_logging; 22 | use crate::mqtt::api::MqttApi; 23 | use crate::teams_ws::api::TeamsAPI; 24 | use crate::traits::Listener; 25 | use crate::tray::create_tray; 26 | use anyhow::Result; 27 | use home_assistant::api::HaApi; 28 | use log::{error, info}; 29 | 30 | #[tokio::main] 31 | async fn main() -> Result<(), Box> { 32 | initialize_logging(); 33 | info!("--------------------"); 34 | info!("Application starting"); 35 | 36 | let mutex = create_mutex(); 37 | 38 | if mutex.is_none() { 39 | exit(1) 40 | } 41 | 42 | // to toggle mute from the tray icon, and let Teams allow the application to listen to its websocket 43 | let toggle_mute = Arc::new(AtomicBool::new(false)); 44 | // used by tray icon to allow exiting the application 45 | let is_running = Arc::new(AtomicBool::new(true)); 46 | let _tray = create_tray(is_running.clone(), toggle_mute.clone()); 47 | let five_seconds = time::Duration::from_secs(5); 48 | let mut save_configuration = true; 49 | 50 | // Aggressive to re-create the connections, but it will handle all APIs, ideal way would 51 | // be to structure to app so that each API has its own loop and message queue, so when it 52 | // comes back online it would pick up the items from the queue and process them. 53 | while is_running.load(Ordering::Relaxed) { 54 | let result = run_apis(is_running.clone(), toggle_mute.clone(), save_configuration).await; 55 | save_configuration = false; 56 | 57 | if result.is_err() { 58 | result.unwrap_or_else(|error| error!("Error encountered: {}", error)); 59 | 60 | // Give the CPU/user/APIs some time to recover 61 | if is_running.load(Ordering::Relaxed) { 62 | tokio::time::sleep(five_seconds).await; 63 | } 64 | } 65 | } 66 | 67 | info!("Application closing"); 68 | 69 | release_mutex(mutex); 70 | exit(0); 71 | } 72 | 73 | async fn run_apis( 74 | is_running: Arc, 75 | toggle_mute: Arc, 76 | save_configuration: bool, 77 | ) -> Result<()> { 78 | let conf = get_configuration(save_configuration); 79 | let teams_api = TeamsAPI::new(&conf.teams); 80 | let listener: Box = if conf.mqtt.url().is_empty() { 81 | Box::new(HaApi::new(conf.ha)?) 82 | } else { 83 | Box::new(MqttApi::new(conf.mqtt)?) 84 | }; 85 | 86 | teams_api 87 | .start_listening( 88 | Arc::new(Mutex::new(listener)), 89 | is_running.clone(), 90 | toggle_mute.clone(), 91 | ) 92 | .await?; 93 | 94 | Ok(()) 95 | } 96 | -------------------------------------------------------------------------------- /src/mqtt/api.rs: -------------------------------------------------------------------------------- 1 | use crate::mqtt::configuration::MqttConfiguration; 2 | use crate::teams_ws::states::TeamsStates; 3 | use crate::traits::Listener; 4 | use crate::utils::bool_to_str; 5 | use async_trait::async_trait; 6 | use rumqttc::{AsyncClient, MqttOptions, QoS}; 7 | use serde_json::json; 8 | use std::sync::atomic::Ordering; 9 | use std::time::Duration; 10 | use tokio::task; 11 | 12 | pub struct MqttApi { 13 | client: AsyncClient, 14 | mqtt_configuration: MqttConfiguration, 15 | } 16 | 17 | impl MqttApi { 18 | pub fn new(mqtt_configuration: MqttConfiguration) -> anyhow::Result { 19 | let mut mqtt_options = MqttOptions::new( 20 | "teams-status", 21 | mqtt_configuration.url(), 22 | mqtt_configuration.port, 23 | ); 24 | 25 | mqtt_options.set_credentials(&mqtt_configuration.username, &mqtt_configuration.password); 26 | mqtt_options.set_keep_alive(Duration::from_secs(5)); 27 | let (client, mut event_loop) = AsyncClient::new(mqtt_options, 10); 28 | 29 | // mqttc requires this to work 30 | task::spawn(async move { while let Ok(_) = event_loop.poll().await {} }); 31 | 32 | Ok(Self { 33 | client, 34 | mqtt_configuration, 35 | }) 36 | } 37 | } 38 | 39 | #[async_trait] 40 | impl Listener for MqttApi { 41 | async fn notify_changed(&mut self, teams_states: &TeamsStates, _: bool) -> anyhow::Result<()> { 42 | let muted = &*bool_to_str(teams_states.is_muted.load(Ordering::Relaxed)); 43 | let video_on = &*bool_to_str(teams_states.is_video_on.load(Ordering::Relaxed)); 44 | let hand_raised = &*bool_to_str(teams_states.is_hand_raised.load(Ordering::Relaxed)); 45 | let in_meeting = &*bool_to_str(teams_states.is_in_meeting.load(Ordering::Relaxed)); 46 | let recording = &*bool_to_str(teams_states.is_recording_on.load(Ordering::Relaxed)); 47 | let background_blurred = 48 | &*bool_to_str(teams_states.is_background_blurred.load(Ordering::Relaxed)); 49 | let sharing = &*bool_to_str(teams_states.is_sharing.load(Ordering::Relaxed)); 50 | let unread_messages = 51 | &*bool_to_str(teams_states.has_unread_messages.load(Ordering::Relaxed)); 52 | 53 | let mqtt_entities = &self.mqtt_configuration.mqtt_entities; 54 | 55 | let payload = json!({ 56 | &mqtt_entities.muted:muted, 57 | &mqtt_entities.video:video_on, 58 | &mqtt_entities.hand_raised:hand_raised, 59 | &mqtt_entities.meeting:in_meeting, 60 | &mqtt_entities.recording:recording, 61 | &mqtt_entities.background_blurred:background_blurred, 62 | &mqtt_entities.sharing:sharing, 63 | &mqtt_entities.unread_messages:unread_messages, 64 | }); 65 | 66 | // todo: log failures 67 | let _ = &self 68 | .client 69 | .publish( 70 | &self.mqtt_configuration.topic, 71 | QoS::AtLeastOnce, 72 | true, 73 | payload.to_string(), 74 | ) 75 | .await?; 76 | 77 | Ok(()) 78 | } 79 | 80 | fn reconnect(&mut self) { 81 | let mut mqtt_options = MqttOptions::new( 82 | "teams-status", 83 | self.mqtt_configuration.url(), 84 | self.mqtt_configuration.port, 85 | ); 86 | 87 | mqtt_options.set_credentials( 88 | &self.mqtt_configuration.username, 89 | &self.mqtt_configuration.password, 90 | ); 91 | mqtt_options.set_keep_alive(Duration::from_secs(5)); 92 | let (client, mut event_loop) = AsyncClient::new(mqtt_options, 10); 93 | 94 | self.client = client; 95 | // mqttc requires this to work 96 | task::spawn(async move { while let Ok(_) = event_loop.poll().await {} }); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/mqtt/configuration.rs: -------------------------------------------------------------------------------- 1 | pub const MQTT: &str = "MQTT"; 2 | pub const MQTT_URL: &str = "URL"; 3 | pub const MQTT_PORT: &str = "Port"; 4 | pub const MQTT_TOPIC: &str = "Topic"; 5 | pub const MQTT_USERNAME: &str = "Username"; 6 | pub const MQTT_PASSWORD: &str = "Password"; 7 | pub const MQTT_ENTITIES: &str = "MQTT Entities"; 8 | pub const MQTT_MUTED: &str = "Muted"; 9 | pub const MQTT_VIDEO: &str = "Video"; 10 | pub const MQTT_HAND_RAISED: &str = "Hand Raised"; 11 | pub const MQTT_MEETING: &str = "Meeting"; 12 | pub const MQTT_RECORDING: &str = "Recording"; 13 | pub const MQTT_BACKGROUND_BLURRED: &str = "Background Blurred"; 14 | pub const MQTT_SHARING: &str = "Sharing"; 15 | pub const MQTT_UNREAD_MESSAGES: &str = "Unread Messages"; 16 | pub const MQTT_PORT_DEFAULT: u16 = 1883; 17 | 18 | pub struct MqttEntities { 19 | pub muted: String, 20 | pub video: String, 21 | pub hand_raised: String, 22 | pub meeting: String, 23 | pub recording: String, 24 | pub background_blurred: String, 25 | pub sharing: String, 26 | pub unread_messages: String, 27 | } 28 | 29 | pub struct MqttConfiguration { 30 | url: String, 31 | pub port: u16, 32 | pub topic: String, 33 | pub username: String, 34 | pub password: String, 35 | pub mqtt_entities: MqttEntities, 36 | } 37 | 38 | impl MqttConfiguration { 39 | pub fn url(&self) -> &str { 40 | &self.url 41 | } 42 | 43 | pub fn set_url(&mut self, url: String) { 44 | self.url = if url.to_lowercase().starts_with("mqtt://") { 45 | url[7..].to_string() 46 | } else { 47 | url 48 | }; 49 | } 50 | } 51 | 52 | pub fn create_mqtt_configuration() -> MqttConfiguration { 53 | let mqtt_entities = MqttEntities { 54 | muted: "muted".to_string(), 55 | video: "video_on".to_string(), 56 | hand_raised: "hand_raised".to_string(), 57 | meeting: "in_meeting".to_string(), 58 | recording: "recording_on".to_string(), 59 | background_blurred: "background_blurred".to_string(), 60 | sharing: "sharing".to_string(), 61 | unread_messages: "unread_messages".to_string(), 62 | }; 63 | 64 | MqttConfiguration { 65 | url: "".to_string(), 66 | port: 1883, 67 | topic: "teams-status".to_string(), 68 | username: "".to_string(), 69 | password: "".to_string(), 70 | mqtt_entities, 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/mqtt/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | pub mod configuration; 3 | -------------------------------------------------------------------------------- /src/mutex.rs: -------------------------------------------------------------------------------- 1 | use log::error; 2 | use md5::{Digest, Md5}; 3 | use windows::core::{HSTRING, PCWSTR}; 4 | use windows::Win32::Foundation::{ 5 | CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, ERROR_SUCCESS, HANDLE, 6 | }; 7 | use windows::Win32::System::Threading::CreateMutexW; 8 | 9 | pub fn release_mutex(mutex: Option) { 10 | unsafe { 11 | CloseHandle(mutex.unwrap()).expect("Unable to close mutex"); 12 | } 13 | } 14 | 15 | pub fn create_mutex() -> Option { 16 | let current_exe = std::env::current_exe().unwrap(); 17 | let mut hasher = Md5::new(); 18 | md5::Digest::update(&mut hasher, current_exe.to_str().unwrap()); 19 | let current_exe_hashed = format!("{:x}", hasher.finalize()); 20 | let mutex_name = format!("Global\\{}", current_exe_hashed); 21 | let h_mutex_name = HSTRING::from(mutex_name); 22 | let p_mutex_name = PCWSTR::from_raw(h_mutex_name.as_ptr()); 23 | 24 | let mutex = unsafe { 25 | let mutex = CreateMutexW(None, true, p_mutex_name); 26 | let error = GetLastError(); 27 | 28 | if error == ERROR_SUCCESS { 29 | Some(mutex.unwrap()) 30 | } else if error == ERROR_ALREADY_EXISTS { 31 | error!("The application is already running: {:?}", error); 32 | None 33 | } else { 34 | error!("Error creating mutex: {:?}", error); 35 | None 36 | } 37 | }; 38 | 39 | mutex 40 | } 41 | -------------------------------------------------------------------------------- /src/teams_log/file_locator.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context}; 2 | use std::path::{Path, PathBuf}; 3 | use std::{fs, io}; 4 | 5 | pub const TEAMS_PREFIX: &str = "MSTeams_"; 6 | const TEAMS_SUFFIX: &str = ".log"; 7 | 8 | // todo: When Teams is unreachable, leave the HA variables as Off 9 | // note: May want to move to https://github.com/uutils/coreutils/tree/main/src/uu/tail at some point 10 | 11 | pub fn locate_latest_log(path: &Path) -> anyhow::Result> { 12 | let mut entries = fs::read_dir(path) 13 | .context("Teams log path is invalid")? 14 | .filter(|entry| { 15 | let dir_entry = entry.as_ref(); 16 | if dir_entry.is_ok() { 17 | let str = dir_entry 18 | .unwrap() 19 | .file_name() 20 | .to_str() 21 | .unwrap_or("") 22 | .to_owned(); 23 | 24 | str.starts_with(TEAMS_PREFIX) && str.ends_with(TEAMS_SUFFIX) 25 | } else { 26 | false 27 | } 28 | }) 29 | .map(|res| res.map(|e| e.path())) 30 | .collect::, io::Error>>()?; 31 | 32 | entries.sort(); 33 | let mut latest_file_name = ""; 34 | 35 | if entries.last().is_some() { 36 | let last_entry = entries.last().unwrap(); 37 | 38 | if last_entry.to_str().is_some() { 39 | latest_file_name = last_entry.to_str().unwrap(); 40 | } 41 | } 42 | 43 | if latest_file_name != "" { 44 | return Ok(Some(PathBuf::from(latest_file_name))); 45 | } 46 | 47 | Ok(None) 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use crate::teams_log::file_locator::{locate_latest_log, TEAMS_PREFIX}; 53 | use chrono::{Datelike, Local}; 54 | use std::env::current_dir; 55 | use std::fs; 56 | use std::path::PathBuf; 57 | 58 | const TEST_PATH: &str = "tests\\file_locator"; 59 | 60 | fn create_file(path: &str, file_name: String) -> PathBuf { 61 | let file_path = current_dir().unwrap().join(path).join(file_name); 62 | fs::write(file_path.to_str().unwrap(), "").unwrap(); 63 | file_path 64 | } 65 | 66 | fn cleanup_test_folder(path: &str) { 67 | fs::remove_dir_all(path).ok(); 68 | fs::create_dir_all(path).unwrap(); 69 | } 70 | 71 | #[test] 72 | // Mimics the naming convention of the Teams log file 73 | fn locate_latest_log_one_file_will_return_correct() { 74 | let current_test_path = TEST_PATH.to_owned() + "/test1"; 75 | cleanup_test_folder(¤t_test_path); 76 | let now = Local::now(); 77 | let file_path = create_file( 78 | ¤t_test_path, 79 | format!( 80 | "{}_{}-{:02}-{:02}_12-18-41.01.log", 81 | TEAMS_PREFIX, 82 | now.year(), 83 | now.month(), 84 | now.day() 85 | ), 86 | ); 87 | let found_file_path = locate_latest_log(&file_path.parent().unwrap()); 88 | assert_eq!(found_file_path.unwrap().unwrap(), file_path.as_path()); 89 | } 90 | 91 | #[test] 92 | // Mimics the naming convention of the Teams log file 93 | fn locate_latest_log_three_files_same_day_will_return_correct() { 94 | let current_test_path = TEST_PATH.to_owned() + "/test2"; 95 | cleanup_test_folder(¤t_test_path); 96 | let now = Local::now(); 97 | 98 | create_file( 99 | ¤t_test_path, 100 | format!( 101 | "{}_{}-{:02}-{:02}_12-17-41.01.log", 102 | TEAMS_PREFIX, 103 | now.year(), 104 | now.month(), 105 | now.day() 106 | ), 107 | ); 108 | let file_path = create_file( 109 | ¤t_test_path, 110 | format!( 111 | "{}_{}-{:02}-{:02}_14-17-41.00.log", 112 | TEAMS_PREFIX, 113 | now.year(), 114 | now.month(), 115 | now.day() 116 | ), 117 | ); 118 | create_file( 119 | ¤t_test_path, 120 | format!( 121 | "{}_{}-{:02}-{:02}_14-16-41.00.log", 122 | TEAMS_PREFIX, 123 | now.year(), 124 | now.month(), 125 | now.day() 126 | ), 127 | ); 128 | create_file( 129 | ¤t_test_path, 130 | format!( 131 | "{}_{}-{:02}-{:02}_13-19-41.02.log", 132 | TEAMS_PREFIX, 133 | now.year(), 134 | now.month(), 135 | now.day() 136 | ), 137 | ); 138 | 139 | let found_file_path = locate_latest_log(&file_path.parent().unwrap()); 140 | assert_eq!(found_file_path.unwrap().unwrap(), file_path.as_path()); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/teams_log/file_notifier.rs: -------------------------------------------------------------------------------- 1 | // todo: Use notify crate to know if there are changes to THAT file, and then run the seek/file parser 2 | // todo: Use notify crate to watch folder and pickup any new log file changes 3 | 4 | // todo: notifier on existing file, file changes, get notification 5 | // todo: new file in folder, get notification 6 | 7 | use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; 8 | use std::path::PathBuf; 9 | use tokio::sync::mpsc::{channel, Receiver}; 10 | 11 | pub struct FileNotifier { 12 | watcher: RecommendedWatcher, 13 | pub rx: Receiver>, 14 | } 15 | 16 | impl FileNotifier { 17 | pub fn new() -> anyhow::Result { 18 | let (tx, rx) = channel(10); 19 | 20 | let watcher = RecommendedWatcher::new( 21 | move |res| { 22 | tx.blocking_send(res).unwrap(); 23 | }, Config::default())?; 24 | 25 | Ok(Self { watcher, rx }) 26 | } 27 | 28 | pub fn watch(&mut self, path: Option<&PathBuf>) -> anyhow::Result<()> { 29 | if path.is_none() { 30 | return Ok(()); 31 | } 32 | 33 | let _ = &self.watcher 34 | .watch( 35 | path.unwrap(), 36 | RecursiveMode::Recursive, 37 | )?; 38 | 39 | Ok(()) 40 | } 41 | 42 | pub fn unwatch(&mut self, path: Option<&PathBuf>) -> anyhow::Result<()> { 43 | if path.is_none() { 44 | return Ok(()); 45 | } 46 | 47 | let _ = &self.watcher 48 | .unwatch( 49 | path.unwrap(), 50 | )?; 51 | 52 | Ok(()) 53 | } 54 | } 55 | 56 | #[cfg(test)] 57 | mod tests { 58 | use crate::teams_log::file_notifier::FileNotifier; 59 | use std::env::current_dir; 60 | use std::{fs}; 61 | use std::time::Duration; 62 | use tokio::time::timeout; 63 | 64 | const TEST_PATH: &str = "tests"; 65 | 66 | #[tokio::test] 67 | async fn watch_folder_edit_file_will_notify() { 68 | let folder_path = current_dir().unwrap().join(TEST_PATH); 69 | let mut file_notifier = FileNotifier::new().unwrap(); 70 | // ensure not to name the watcher "_" as it will get destroyed right away 71 | file_notifier.watch(Some(&folder_path)).unwrap(); 72 | fs::write("C:\\Gits\\teams-status-rs\\tests\\log.txt", "test").unwrap(); 73 | 74 | let res = file_notifier.rx.recv().await.unwrap(); 75 | assert!(res.is_ok()); 76 | } 77 | 78 | // todo: use a different folder for each test as this one fails when run alongside the others 79 | #[tokio::test] 80 | async fn watch_folder_do_nothing_will_not_notify() { 81 | let folder_path = current_dir().unwrap().join(TEST_PATH); 82 | let mut file_notifier = FileNotifier::new().unwrap(); 83 | // ensure not to name the watcher "_" as it will get destroyed right away 84 | file_notifier.watch(Some(&folder_path)).unwrap(); 85 | 86 | let res = timeout(Duration::new(1, 0), file_notifier.rx.recv()).await; 87 | assert!(res.is_err()); 88 | } 89 | 90 | #[tokio::test] 91 | async fn watch_file_edit_file_will_notify() { 92 | let file_path = current_dir().unwrap().join(TEST_PATH).join("log.txt"); 93 | let mut file_notifier = FileNotifier::new().unwrap(); 94 | // ensure not to name the watcher "_" as it will get destroyed right away 95 | file_notifier.watch(Some(&file_path)).unwrap(); 96 | fs::write("C:\\Gits\\teams-status-rs\\tests\\log.txt", "test").unwrap(); 97 | 98 | let res = file_notifier.rx.recv().await.unwrap(); 99 | assert!(res.is_ok()); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/teams_log/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod file_locator; 2 | mod file_notifier; 3 | mod parser; 4 | pub mod states; 5 | mod watcher; 6 | -------------------------------------------------------------------------------- /src/teams_log/parser.rs: -------------------------------------------------------------------------------- 1 | // parses the given file from position x to locate the 2 | // todo: there is a lot of logic in the Teams.ps1 file regarding getting the status right 3 | 4 | use regex::Regex; 5 | use std::fs::File; 6 | use std::io::{BufRead, BufReader, Seek, SeekFrom}; 7 | use std::path::PathBuf; 8 | 9 | /// Returns the availability as a string, as well as the last position parsed 10 | pub fn get_last_state(path: Option<&PathBuf>, position: u64) -> anyhow::Result<(Option, u64)> { 11 | if path.is_none() { 12 | return Ok((None, 0)); 13 | } 14 | 15 | let mut f = File::open(path.unwrap()).unwrap(); 16 | f.seek(SeekFrom::Start(position)).unwrap(); 17 | let position = f.metadata()?.len(); 18 | let mut last_status: Option = None; 19 | 20 | let reader = BufReader::new(&f); 21 | let re = Regex::new(r"UserPresenceAction:.*availability: (?[a-zA-Z]*)").unwrap(); 22 | for line in reader.lines() { 23 | let log_line = line.unwrap(); 24 | let result = re.captures(&log_line); 25 | 26 | if result.is_some() { 27 | let status = result.unwrap().name("status"); 28 | if status.is_some() { 29 | last_status = Some(status.unwrap().as_str().to_owned()); 30 | } 31 | } 32 | } 33 | 34 | Ok((last_status, position)) 35 | } 36 | 37 | #[cfg(test)] 38 | mod tests { 39 | use crate::teams_log::parser::get_last_state; 40 | use std::env::current_dir; 41 | 42 | const TEST_PATH: &str = "tests"; 43 | 44 | #[test] 45 | fn get_last_state_test_file_will_return_correct_state() { 46 | let file_path = current_dir() 47 | .unwrap() 48 | .join(TEST_PATH) 49 | .join("MSTeams_2024-02-07_14-47-09.05.log"); 50 | 51 | let (status, _) = get_last_state(Some(&file_path), 0).unwrap(); 52 | assert_eq!("Available", status.unwrap()); 53 | } 54 | 55 | #[test] 56 | fn get_last_state_test_file_position_further_than_last_update_will_last_position_and_none_status() { 57 | let file_path = current_dir() 58 | .unwrap() 59 | .join(TEST_PATH) 60 | .join("MSTeams_2024-02-07_14-47-09.05.log"); 61 | 62 | let (status, position) = get_last_state(Some(&file_path), 1670531).unwrap(); 63 | assert!(position > 1670531); 64 | assert_eq!(status, None); 65 | } 66 | 67 | #[test] 68 | fn get_last_state_test_file_returned_position_will_be_higher() { 69 | let file_path = current_dir() 70 | .unwrap() 71 | .join(TEST_PATH) 72 | .join("MSTeams_2024-02-07_14-47-09.05.log"); 73 | 74 | let (_, position) = get_last_state(Some(&file_path), 0).unwrap(); 75 | assert!(position > 0); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/teams_log/states.rs: -------------------------------------------------------------------------------- 1 | // use std::sync::atomic::AtomicBool; 2 | 3 | // enum TSAvailability { 4 | // Available, 5 | // Busy, 6 | // Away, 7 | // BeRightBack, 8 | // DoNotDisturb, 9 | // Offline, 10 | // Focusing, 11 | // Presenting, // already in WS? Same as sharing? 12 | // InAMeeting, // already in WS?? 13 | // OnThePhone, // already in WS?? 14 | // } 15 | // 16 | // pub struct TeamsLogStates { 17 | // pub availability: AtomicBool, 18 | // } 19 | 20 | // impl HelloWorld { 21 | // fn as_str(&self) -> &'static str { 22 | // match self { 23 | // HelloWorld::Hello => "Hello", 24 | // HelloWorld::World => "World" 25 | // } 26 | // } 27 | // } 28 | 29 | // If ($TeamsStatus -like "*, availability: $($_.value.keys[0])}" -or ` 30 | // $TeamsStatus -like "*Navigation starting: about:blank?entityType=$($_.key)*") { 31 | // $Status = $($_.value.values[0]) 32 | // If ($Activity -eq $taInACall -And $Status -eq $tsDoNotDisturb) { 33 | // $Status = $tsPresenting 34 | // } ElseIf ($Activity -eq $taInACall) { 35 | // $Status = $tsInAMeeting 36 | // } 37 | // } 38 | -------------------------------------------------------------------------------- /src/teams_log/watcher.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::PathBuf; 3 | use crate::teams_log::file_locator::{locate_latest_log, TEAMS_PREFIX}; 4 | use crate::teams_log::file_notifier::FileNotifier; 5 | use crate::teams_log::parser::get_last_state; 6 | 7 | // higher-level unit, will coordinate between locator, notifier and parser 8 | pub struct Watcher { 9 | teams_base_path: PathBuf, 10 | file_notifier: FileNotifier, 11 | } 12 | 13 | impl Watcher { 14 | pub fn new() -> Self { 15 | let file_notifier = FileNotifier::new().unwrap(); 16 | let mut app_data = std::env::var("APPDATA").unwrap(); 17 | app_data = app_data.replace("\\Roaming", ""); 18 | let teams_base_path: PathBuf = [r"C:\", &app_data, "Local", "Packages", "MSTeams_8wekyb3d8bbwe", "LocalCache", "Microsoft", "MSTeams", "Logs"].iter().collect(); 19 | Self { teams_base_path, file_notifier } 20 | } 21 | 22 | pub async fn watch_teams_files(&mut self) -> anyhow::Result<()> { 23 | let mut latest_log_file = locate_latest_log(&self.teams_base_path)?; 24 | 25 | let (mut last_state, mut file_position) = get_last_state(latest_log_file.as_ref(), 0).unwrap(); 26 | // todo: call API here 27 | fs::write(r"C:\Users\antoi\Documents\teams_log.txt", last_state.unwrap_or("None".to_string())).unwrap(); 28 | 29 | // self.file_notifier.watch(latest_log_file.as_ref())?; 30 | self.file_notifier.watch(Some(&self.teams_base_path))?; 31 | 32 | // todo: this only gets triggered when refreshing the file in Notepad++ 33 | while let Some(res) = self.file_notifier.rx.recv().await { 34 | match res { 35 | Ok(res) => { 36 | if (res.paths.len() == 0) || res.paths.first().is_none() { 37 | continue; 38 | } 39 | 40 | let changed_file = res.paths.first().unwrap(); 41 | 42 | if changed_file.file_name().is_none() || changed_file.file_name().unwrap().to_str().is_none() { 43 | continue; 44 | } 45 | 46 | if changed_file.file_name().unwrap().to_str().unwrap().starts_with(TEAMS_PREFIX) { 47 | // Is it a different file then the latest log file? 48 | if Some(changed_file) != latest_log_file.as_ref() { 49 | let new_latest_log_file = locate_latest_log(&self.teams_base_path)?; 50 | 51 | if new_latest_log_file != latest_log_file { 52 | self.file_notifier.unwatch(latest_log_file.as_ref())?; 53 | // latest_log_file = res.paths.first()?.into_path_buf(); 54 | latest_log_file = new_latest_log_file; 55 | self.file_notifier.watch(latest_log_file.as_ref())?; 56 | file_position = 0; 57 | } 58 | } 59 | 60 | let (new_last_state, mew_file_position) = get_last_state(latest_log_file.as_ref(), file_position).unwrap(); 61 | last_state = new_last_state; 62 | file_position = mew_file_position; 63 | 64 | if last_state.is_some() { 65 | // todo: we will be calling API here 66 | fs::write(r"C:\Users\antoi\Documents\teams_log.txt", last_state.clone().unwrap_or("None".to_string())).unwrap(); 67 | println!("{}", last_state.clone().unwrap()); 68 | } 69 | } 70 | } 71 | // todo: not sure I should exit here 72 | Err(res) => break, 73 | } 74 | } 75 | 76 | Ok(()) 77 | } 78 | } 79 | 80 | #[cfg(test)] 81 | mod tests { 82 | use crate::teams_log::watcher::Watcher; 83 | 84 | #[tokio::test] 85 | async fn end_to_end_test() { 86 | let mut watcher = Watcher::new(); 87 | watcher.watch_teams_files().await.unwrap(); 88 | } 89 | } -------------------------------------------------------------------------------- /src/teams_ws/api.rs: -------------------------------------------------------------------------------- 1 | use crate::teams_ws::configuration::{ 2 | change_teams_configuration, TeamsConfiguration, TEAMS, TEAMS_API_TOKEN, 3 | }; 4 | use crate::teams_ws::states::TeamsStates; 5 | use crate::traits::Listener; 6 | use anyhow::Context; 7 | use futures_util::{future, pin_mut, SinkExt, StreamExt}; 8 | use json::JsonValue; 9 | use log::{error, info}; 10 | use std::sync::atomic::{AtomicBool, Ordering}; 11 | use std::sync::Arc; 12 | use std::time::Duration; 13 | use tokio::sync::Mutex; 14 | use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; 15 | 16 | const JSON_MEETING_UPDATE: &str = "meetingUpdate"; 17 | const JSON_MEETING_STATE: &str = "meetingState"; 18 | const JSON_IS_MUTED: &str = "isMuted"; 19 | const JSON_IS_VIDEO_ON: &str = "isVideoOn"; 20 | const JSON_IS_HAND_RAISED: &str = "isHandRaised"; 21 | const JSON_IS_IN_MEETING: &str = "isInMeeting"; 22 | const JSON_IS_RECORDING_ON: &str = "isRecordingOn"; 23 | const JSON_IS_BACKGROUND_BLURRED: &str = "isBackgroundBlurred"; 24 | const JSON_IS_SHARING: &str = "isSharing"; 25 | const JSON_HAS_UNREAD_MESSAGES: &str = "hasUnreadMessages"; 26 | const JSON_TOKEN_REFRESH: &str = "tokenRefresh"; 27 | 28 | pub struct TeamsAPI { 29 | pub teams_states: Arc, 30 | pub url: String, 31 | } 32 | 33 | impl TeamsAPI { 34 | pub fn new(conf: &TeamsConfiguration) -> Self { 35 | let teams_states = Arc::new(TeamsStates { 36 | is_muted: AtomicBool::new(false), 37 | prev_is_muted: AtomicBool::new(false), 38 | is_video_on: AtomicBool::new(false), 39 | prev_is_video_on: AtomicBool::new(false), 40 | is_hand_raised: AtomicBool::new(false), 41 | prev_is_hand_raised: AtomicBool::new(false), 42 | is_in_meeting: AtomicBool::new(false), 43 | prev_is_in_meeting: AtomicBool::new(false), 44 | is_recording_on: AtomicBool::new(false), 45 | prev_is_recording_on: AtomicBool::new(false), 46 | is_background_blurred: AtomicBool::new(false), 47 | prev_is_background_blurred: AtomicBool::new(false), 48 | is_sharing: AtomicBool::new(false), 49 | prev_is_sharing: AtomicBool::new(false), 50 | has_unread_messages: AtomicBool::new(false), 51 | prev_has_unread_messages: AtomicBool::new(false), 52 | }); 53 | 54 | let api_token = if !conf.api_token.is_empty() { 55 | format!("token={}&", &conf.api_token) 56 | } else { 57 | "".to_string() 58 | }; 59 | let url = format!( 60 | "{url}?{api_token}protocol-version=2.0.0&manufacturer=HA-Integration&device=MyPC&app=teams-status-rs&app-version=1.0", 61 | url = conf.url, 62 | api_token = api_token); 63 | 64 | Self { teams_states, url } 65 | } 66 | 67 | pub async fn start_listening( 68 | &self, 69 | listener: Arc>>, 70 | is_running: Arc, 71 | toggle_mute: Arc, 72 | ) -> anyhow::Result<()> { 73 | let url_local = url::Url::parse(&self.url)?; 74 | let (ws_stream, _) = connect_async(url_local.as_str()) 75 | .await 76 | .with_context(|| "Failed to connect")?; 77 | let (mut write, read) = ws_stream.split(); 78 | let force_update = Arc::new(AtomicBool::new(true)); 79 | let ws_to_parser = { 80 | read.for_each(|message| async { 81 | if message.is_ok() { 82 | let data = &message.unwrap().into_data(); 83 | let json = String::from_utf8_lossy(data); 84 | info!("{}", json); 85 | 86 | let parse_result = parse_data_and_notify_listener( 87 | &json, 88 | listener.clone(), 89 | self.teams_states.clone(), 90 | force_update.clone(), 91 | ) 92 | .await; 93 | 94 | if parse_result.is_err() { 95 | error!( 96 | "Unable to parse or notify listener, abandoning: {}", 97 | parse_result.unwrap_err() 98 | ); 99 | } 100 | } 101 | }) 102 | }; 103 | 104 | let running_future = async { 105 | let one_second = Duration::from_secs(1); 106 | 107 | while is_running.load(Ordering::Relaxed) { 108 | tokio::time::sleep(one_second).await; 109 | 110 | if toggle_mute.load(Ordering::Relaxed) { 111 | let msg = Message::text( 112 | r#"{"requestId":1,"apiVersion":"2.0.0","action":"toggle-mute"}"#, 113 | ); 114 | 115 | write.send(msg).await.unwrap(); 116 | toggle_mute.swap(false, Ordering::Relaxed); 117 | } 118 | } 119 | 120 | info!("Application close requested"); 121 | }; 122 | 123 | pin_mut!(running_future, ws_to_parser); 124 | future::select(running_future, ws_to_parser).await; 125 | Ok(()) 126 | } 127 | } 128 | 129 | async fn update_value( 130 | teams_state_value: &AtomicBool, 131 | answer: &JsonValue, 132 | json_value_name: &str, 133 | ) -> bool { 134 | let new_value = answer[JSON_MEETING_UPDATE][JSON_MEETING_STATE][json_value_name] 135 | .as_bool() 136 | .unwrap_or_else(|| { 137 | error!("Unable to locate {} variable in JSON", json_value_name); 138 | false 139 | }); 140 | 141 | teams_state_value.swap(new_value, Ordering::Relaxed) != new_value 142 | } 143 | 144 | async fn parse_data_and_notify_listener( 145 | json: &str, 146 | listener: Arc>>, 147 | teams_states: Arc, 148 | force_update: Arc, 149 | ) -> anyhow::Result<()> { 150 | let answer = json::parse(&json.to_string()).unwrap_or(json::parse("{}")?); 151 | 152 | if answer.has_key(JSON_MEETING_UPDATE) { 153 | let mut has_changed = update_value(&teams_states.is_muted, &answer, JSON_IS_MUTED).await; 154 | has_changed |= update_value(&teams_states.is_video_on, &answer, JSON_IS_VIDEO_ON).await; 155 | has_changed |= 156 | update_value(&teams_states.is_hand_raised, &answer, JSON_IS_HAND_RAISED).await; 157 | has_changed |= update_value(&teams_states.is_in_meeting, &answer, JSON_IS_IN_MEETING).await; 158 | has_changed |= 159 | update_value(&teams_states.is_recording_on, &answer, JSON_IS_RECORDING_ON).await; 160 | has_changed |= update_value( 161 | &teams_states.is_background_blurred, 162 | &answer, 163 | JSON_IS_BACKGROUND_BLURRED, 164 | ) 165 | .await; 166 | has_changed |= update_value(&teams_states.is_sharing, &answer, JSON_IS_SHARING).await; 167 | has_changed |= update_value( 168 | &teams_states.has_unread_messages, 169 | &answer, 170 | JSON_HAS_UNREAD_MESSAGES, 171 | ) 172 | .await; 173 | 174 | let force_update = force_update.swap(false, Ordering::Relaxed); 175 | 176 | if force_update || has_changed { 177 | // Issue!: This will only run once regardless of MAX_RETRIES 178 | // for some reason after a reconnect the notify_changed will get a pass no matter what 179 | const MAX_RETRIES: i32 = 3; 180 | for i in 1..MAX_RETRIES { 181 | let result = listener 182 | .lock() 183 | .await 184 | .notify_changed(&teams_states, force_update) 185 | .await; 186 | 187 | if result.is_ok() || (i == MAX_RETRIES) { 188 | result?; 189 | break; 190 | } 191 | // we will try to reconnect if the connection failed 192 | else if i < MAX_RETRIES { 193 | error!("{}: Reconnecting and retrying...", result.unwrap_err()); 194 | tokio::time::sleep(Duration::from_secs(1)).await; 195 | listener.lock().await.reconnect(); 196 | } 197 | } 198 | } 199 | } else if answer.has_key(JSON_TOKEN_REFRESH) && !answer[JSON_TOKEN_REFRESH].is_empty() { 200 | change_teams_configuration( 201 | TEAMS, 202 | TEAMS_API_TOKEN, 203 | &answer[JSON_TOKEN_REFRESH].to_string(), 204 | ) 205 | } 206 | 207 | Ok(()) 208 | } 209 | -------------------------------------------------------------------------------- /src/teams_ws/configuration.rs: -------------------------------------------------------------------------------- 1 | use ini::Ini; 2 | use log::info; 3 | 4 | pub const TEAMS: &str = "Teams"; 5 | pub const TEAMS_URL: &str = "URL"; 6 | pub const TEAMS_API_TOKEN: &str = "API Token"; 7 | 8 | pub struct TeamsConfiguration { 9 | pub url: String, 10 | pub api_token: String, 11 | } 12 | 13 | pub fn create_teams_configuration() -> TeamsConfiguration { 14 | TeamsConfiguration { 15 | url: "ws://localhost:8124".to_string(), 16 | api_token: "".to_string(), 17 | } 18 | } 19 | 20 | pub fn change_teams_configuration(section: &str, key: &str, value: &str) { 21 | let mut i = Ini::load_from_file("conf.ini").unwrap_or_else(|err| { 22 | info!( 23 | "The file conf.ini could not be loaded but should already exist, exiting application: {}", 24 | err.to_string() 25 | ); 26 | panic!("{}", err.to_string()); 27 | }); 28 | 29 | i.with_section(Some(section)).set(key, value); 30 | // for (sec, prop) in i.iter() { 31 | // for (k, v) in prop.iter() { 32 | // if (sec == Some(section)) && (k == key) { 33 | // prop. v = value; 34 | // } 35 | // } 36 | // } 37 | 38 | i.write_to_file("conf.ini").unwrap(); 39 | } 40 | -------------------------------------------------------------------------------- /src/teams_ws/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | pub mod configuration; 3 | pub mod states; 4 | -------------------------------------------------------------------------------- /src/teams_ws/states.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::AtomicBool; 2 | 3 | pub struct TeamsStates { 4 | pub is_muted: AtomicBool, 5 | pub prev_is_muted: AtomicBool, 6 | pub is_video_on: AtomicBool, 7 | pub prev_is_video_on: AtomicBool, 8 | pub is_hand_raised: AtomicBool, 9 | pub prev_is_hand_raised: AtomicBool, 10 | pub is_in_meeting: AtomicBool, 11 | pub prev_is_in_meeting: AtomicBool, 12 | pub is_recording_on: AtomicBool, 13 | pub prev_is_recording_on: AtomicBool, 14 | pub is_background_blurred: AtomicBool, 15 | pub prev_is_background_blurred: AtomicBool, 16 | pub is_sharing: AtomicBool, 17 | pub prev_is_sharing: AtomicBool, 18 | pub has_unread_messages: AtomicBool, 19 | pub prev_has_unread_messages: AtomicBool, 20 | } 21 | -------------------------------------------------------------------------------- /src/traits.rs: -------------------------------------------------------------------------------- 1 | use crate::teams_ws::states::TeamsStates; 2 | use async_trait::async_trait; 3 | 4 | pub trait StopController {} 5 | 6 | #[async_trait] 7 | pub trait Listener { 8 | async fn notify_changed( 9 | &mut self, 10 | teams_states: &TeamsStates, 11 | force_update: bool, 12 | ) -> anyhow::Result<()>; 13 | fn reconnect(&mut self); 14 | } 15 | -------------------------------------------------------------------------------- /src/tray/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::traits::StopController; 2 | use std::sync::atomic::{AtomicBool, Ordering}; 3 | use std::sync::Arc; 4 | use tray_item::{IconSource, TrayItem}; 5 | 6 | pub struct TrayWindows { 7 | _tray: TrayItem, 8 | } 9 | 10 | impl TrayWindows { 11 | pub fn new(is_running: Arc, toggle_mute: Arc) -> Self { 12 | let mut tray = TrayItem::new("Teams Status", IconSource::Resource("default-icon")).unwrap(); 13 | 14 | tray.add_menu_item("Toggle Mute", move || { 15 | toggle_mute.store(true, Ordering::Relaxed); 16 | }) 17 | .unwrap(); 18 | 19 | tray.add_menu_item("Quit", move || { 20 | is_running.store(false, Ordering::Relaxed); 21 | }) 22 | .unwrap(); 23 | 24 | TrayWindows { _tray: tray } 25 | } 26 | } 27 | 28 | impl StopController for TrayWindows {} 29 | 30 | pub fn create_tray( 31 | is_running: Arc, 32 | toggle_mute: Arc, 33 | ) -> Box { 34 | let tray = TrayWindows::new(is_running, toggle_mute); 35 | Box::new(tray) 36 | } 37 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | // Tried a couple of different encoders like age and simple_crypt and I was unable to convert 2 | // the encrypted data into utf8 to write into the ini file, base64 is better than nothing.. tbc 3 | use magic_crypt::{new_magic_crypt, MagicCryptTrait}; 4 | 5 | const CRYPTO_KEY: &str = env!("CRYPTO_KEY"); 6 | const ENCODED_PREFIX: &str = "en//"; 7 | pub fn bool_to_str(bool: bool) -> String { 8 | return if bool { 9 | "on".to_string() 10 | } else { 11 | "off".to_string() 12 | }; 13 | } 14 | 15 | pub fn encrypt(value: &str) -> String { 16 | let mc = new_magic_crypt!(CRYPTO_KEY, 256); 17 | 18 | format!( 19 | "{prefix}{value}", 20 | prefix = ENCODED_PREFIX, 21 | value = mc.encrypt_str_to_base64(value) 22 | ) 23 | } 24 | 25 | fn decrypt(value: &str) -> String { 26 | let mc = new_magic_crypt!(CRYPTO_KEY, 256); 27 | mc.decrypt_base64_to_string(value).unwrap() 28 | } 29 | 30 | pub fn decrypt_if_needed(value: &str) -> String { 31 | if value.starts_with(ENCODED_PREFIX) { 32 | decrypt(&value[ENCODED_PREFIX.len()..]) 33 | } else { 34 | value.to_string() 35 | } 36 | } 37 | --------------------------------------------------------------------------------