├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ ├── check-pull-request-to-develop.yml │ ├── forbid-contributors-pull-requests-to-main.yml │ ├── release.yml │ └── update-version.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── PRIVACY-POLICY.md ├── README.md ├── SECURITY.md ├── docs ├── Authorized User Features.md ├── Bot Token Encryption.md ├── Message Objects Examples.md ├── Telegram Sync Insider Features.md └── Template Variables List.md ├── esbuild.config.mjs ├── install-plugin.mjs ├── manifest.json ├── package-lock.json ├── package.json ├── post-build.mjs ├── release-notes.mjs ├── src ├── ConnectionStatusIndicator.ts ├── main.ts ├── settings │ ├── Settings.ts │ ├── messageDistribution.ts │ ├── modals │ │ ├── AdvancedSettings.ts │ │ ├── BotSettings.ts │ │ ├── MessageDistributionRules.ts │ │ ├── PinCode.ts │ │ ├── ProcessOldMessagesSettings.ts │ │ └── UserLogin.ts │ └── suggesters │ │ ├── FileSuggester.ts │ │ └── suggest.ts ├── telegram │ ├── bot │ │ ├── bot.ts │ │ ├── message │ │ │ ├── convertToMarkdown.ts │ │ │ ├── donation.ts │ │ │ ├── filterEvaluations.ts │ │ │ ├── getters.ts │ │ │ ├── handlers.ts │ │ │ └── processors.ts │ │ ├── progressBar.ts │ │ └── tooManyRequests.ts │ ├── convertors │ │ ├── botFileToMessageMedia.ts │ │ ├── botMessageToClientMessage.ts │ │ └── clientMessageToBotMessage.ts │ └── user │ │ ├── client.ts │ │ ├── config.ts │ │ ├── sync.ts │ │ └── user.ts ├── types │ └── node-telegram-bot-api │ │ └── index.d.ts └── utils │ ├── arrayUtils.ts │ ├── crypto256.ts │ ├── dateUtils.ts │ ├── fsUtils.ts │ ├── logUtils.ts │ └── queues.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": ["prettier", "@typescript-eslint"], 6 | "extends": [ 7 | "prettier", 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "parserOptions": { 13 | "sourceType": "module" 14 | }, 15 | "rules": { 16 | "no-unused-vars": "off", 17 | "@typescript-eslint/no-unused-vars": [ 18 | "warn", 19 | { 20 | "vars": "all", 21 | "args": "after-used", 22 | "ignoreRestSiblings": false, 23 | "varsIgnorePattern": "^_", 24 | "argsIgnorePattern": "^_" 25 | } 26 | ], 27 | "@typescript-eslint/ban-ts-comment": "off", 28 | "no-prototype-builtins": "off", 29 | "@typescript-eslint/no-empty-function": "off", 30 | "prettier/prettier": ["error"], 31 | "linebreak-style": ["error", "unix"], 32 | "no-mixed-spaces-and-tabs": ["error", "smart-tabs"] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: soberhacker 2 | ko_fi: soberhacker 3 | custom: 4 | [ 5 | "https://www.buymeacoffee.com/soberhacker", 6 | "https://www.paypal.com/donate/?hosted_button_id=VYSCUZX8MYGCU", 7 | "https://nowpayments.io/donation?api_key=JMM7NE1-M4X4JY6-N8EK1GJ-H8XQXFK", 8 | ] 9 | -------------------------------------------------------------------------------- /.github/workflows/check-pull-request-to-develop.yml: -------------------------------------------------------------------------------- 1 | name: Check pull request 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | branches: 8 | - develop 9 | 10 | jobs: 11 | check-pr: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Print github 15 | run: echo '${{ toJson(github) }}' 16 | 17 | - name: Checkout repository 18 | uses: actions/checkout@v3 19 | 20 | - name: Set up Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: "18.x" 24 | 25 | - name: Install dependencies 26 | run: npm ci 27 | 28 | - name: Run linter 29 | run: npm run lint 30 | continue-on-error: true 31 | 32 | - name: Report lint errors 33 | if: ${{ failure() }} 34 | run: echo "There are linting errors. Please use 'npm run lint-fix' to address them." 35 | -------------------------------------------------------------------------------- /.github/workflows/forbid-contributors-pull-requests-to-main.yml: -------------------------------------------------------------------------------- 1 | name: Prevent contributors from making PRs to the main branch 2 | on: 3 | pull_request_target: 4 | branches: 5 | - main 6 | types: 7 | - opened 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | jobs: 14 | close_pull_request: 15 | runs-on: ubuntu-latest 16 | if: ${{ github.head_ref != 'develop' || github.event.pull_request.author_association != 'OWNER' }} 17 | steps: 18 | - name: Print github 19 | run: echo '${{ toJson(github) }}' 20 | - name: Closing pull request with comment 21 | run: | 22 | echo "All PRs from contributors should be based on, and directed towards, the develop branch. Closing this PR..." 23 | gh pr close ${{ github.event.pull_request.number }} -c "Auto-closing: all PRs from contributors should be based on, and directed towards, the develop branch." 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - closed 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | jobs: 15 | release: 16 | if: ${{ github.event.pull_request.merged == true && github.head_ref == 'develop' && github.event.pull_request.author_association == 'OWNER' }} 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Print github 20 | run: echo '${{ toJson(github) }}' 21 | 22 | - name: Checkout repository 23 | uses: actions/checkout@v3 24 | 25 | - name: Set up Node.js 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: "18.x" 29 | 30 | - name: Build plugin main.js 31 | id: build_plugin 32 | run: | 33 | git config core.autocrlf false 34 | git config core.eol lf 35 | npm ci 36 | npm run build 37 | 38 | TAG_NAME=$(node -p "require('./package.json').version") 39 | echo "TAG_NAME=$TAG_NAME" >> $GITHUB_ENV 40 | 41 | - name: Create release 42 | uses: softprops/action-gh-release@v1 43 | with: 44 | files: | 45 | main.js 46 | manifest.json 47 | styles.css 48 | token: ${{ secrets.GITHUB_TOKEN }} 49 | tag_name: ${{ env.TAG_NAME }} 50 | prerelease: false 51 | draft: false 52 | 53 | - name: Create and merge pull request from main to develop 54 | run: | 55 | gh pr create -B develop -H main --title 'Merge main into develop' --body 'Merge main into develop' 56 | gh pr merge main --merge --auto --body 'Merge main into develop' 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | -------------------------------------------------------------------------------- /.github/workflows/update-version.yml: -------------------------------------------------------------------------------- 1 | name: Update version of plugin 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | jobs: 15 | update-version: 16 | if: ${{ github.head_ref == 'develop' && github.event.pull_request.author_association == 'OWNER' }} 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Print github 20 | run: echo '${{ toJson(github) }}' 21 | 22 | - name: Checkout repository 23 | uses: actions/checkout@v3 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Set up Node.js 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: "18.x" 31 | 32 | - name: Fetch all tags 33 | run: | 34 | git tag 35 | git fetch --tags 36 | 37 | - name: Update version in CHANGELOG.md and package.json 38 | id: release 39 | uses: google-github-actions/release-please-action@v3 40 | with: 41 | token: ${{ secrets.GITHUB_TOKEN }} 42 | release-type: node 43 | command: release-pr 44 | package-name: obsidian-telegram-sync 45 | include-v-in-tag: false 46 | pull-request-title-pattern: "chore: update version of package to ${version}" 47 | pull-request-header: "Update version in CHANGELOG.md and package.json" 48 | default-branch: develop 49 | labels: automerge 50 | 51 | - name: Print release outputs 52 | env: 53 | RELEASE_OUTPUTS: ${{ toJSON(steps.release.outputs) }} 54 | run: echo $RELEASE_OUTPUTS 55 | 56 | - name: Get number of release PR 57 | if: ${{steps.release.outputs.pr}} 58 | run: echo "RELEASE_PR=$((${{ github.event.number }} + 1))" >> $GITHUB_ENV 59 | 60 | - name: Merge release PR 61 | if: ${{steps.release.outputs.pr}} 62 | uses: pascalgn/automerge-action@v0.15.6 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | MERGE_REMOVE_LABELS: automerge 66 | MERGE_METHOD: squash 67 | MERGE_COMMIT_MESSAGE: pull-request-title 68 | MERGE_FORKS: false 69 | PULL_REQUEST: ${{ env.RELEASE_PR }} 70 | 71 | - name: Update manifest.json and versions.json 72 | if: ${{steps.release.outputs.pr}} 73 | run: | 74 | git config core.autocrlf false 75 | git config core.eol lf 76 | git config user.name github-actions[bot] 77 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 78 | git fetch origin 79 | git checkout develop 80 | npm ci 81 | npm run release-notes-check 82 | npm run version 83 | TAG_NAME=$(node -p "require('./package.json').version") 84 | git commit -m "chore: update version of plugin to $TAG_NAME" 85 | git push origin develop 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # npm 5 | node_modules 6 | 7 | # Don't include the compiled main.js file in the repo. 8 | # They should be uploaded to GitHub releases instead. 9 | main.js 10 | 11 | # Exclude sourcemaps 12 | *.map 13 | 14 | # obsidian 15 | data.json 16 | 17 | # Exclude macOS Finder (System Explorer) View States 18 | .DS_Store 19 | .devcontainer/devcontainer.json 20 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "printWidth": 120, 4 | "endOfLine": "lf" 5 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## 📢 How You Can Contribute Effectively 2 | 3 | To ensure your contributions have the greatest impact, there are two important steps to follow: 4 | 5 | 1. **Notify About Your Task 📨** 6 | Before starting work on a specific task (bug fix or feature enhancement), please send me a direct message [@soberhacker](https://t.me/soberhacker). Let me know which issue you intend to work on, so I can mark it as being in progress by you. 7 | 8 | 2. **Earn Stars for Your Work 🌟** 9 | If the task you're working on is mentioned on the [Telegram channel](https://t.me/obsidian_telegram_sync), all stars related to that feature or bug will be credited to you once your pull request is successfully merged. This helps acknowledge your contribution to the community and highlights the real value of your work. 10 | 11 | --- 12 | 13 | ## 👨‍💻 Steps to Contribute Code 14 | 15 | I'm thrilled you're considering making a contribution to my project! The process is straightforward, and I've outlined the steps below. 16 | 17 | 1. **Fork the Repository**: To start, click the "Fork" button at the top of the repository page. 18 | 19 | 2. **Clone the Repository**: In your new fork, click the "Code" button and copy the URL. Then, open your terminal and run the command `git clone [URL]`. 20 | 21 | 3. **Create a New Branch**: Navigate to the project directory by running `cd [project-name]` in your terminal. Switch to the `develop` branch with `git checkout develop` and create a new branch by running `git checkout -b [branch-name]`. 22 | 23 | 4. **Make Your Changes**: Now's the time to contribute your changes to the project. Once you're finished, add your changes with `git add .`. 24 | 25 | 5. **Do Not Update the Plugin Version**: Please refrain from manually updating the plugin's version. I utilize GitHub Actions to automatically update the version in the following files: manifest.json, package.json, versions.json, package-lock.json, and CHANGELOG.md. 26 | 27 | 6. **Commit Your Changes**: Commit your changes with `git commit -m "[commit-message]"`. 28 | 29 | 7. **Commit Message Guidelines**: We follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification for our commit messages. Here are the basic rules: 30 | - Commits must be prefixed with a type, which consists of a noun, feat, fix, etc., followed by a colon and a space. 31 | - The type `feat` should be used when a new feature is added. 32 | - The type `fix` should be used when a bug is fixed. 33 | - Use `docs` for documentation changes. 34 | - Use `style` for formatting changes that do not affect the code's meaning. 35 | - Use `refactor` when code is changed without adding features or fixing bugs. 36 | - Use `perf` for code changes that improve performance. 37 | - Use `test` when adding missing tests or correcting existing tests. 38 | - The type may be followed by a scope (optional). 39 | - A description must immediately follow the space after the type/scope prefix. 40 | - The description is a short description of the code changes, e.g., "Add new user registration module". 41 | - If needed, provide a longer description after the short description. Separate them by an empty line. 42 | 43 | 8. **Push Your Changes**: Push your changes to your fork on GitHub by running `git push origin [branch-name]`. 44 | 45 | 9. **Create a Pull Request**: Go back to your fork on GitHub, select your branch, and click "New pull request". Make sure the target branch for the pull request is `develop` in the original repository. 46 | 47 | **🤝 Important Note**: Please make sure to adhere to the coding and commit standards. Commits that do not comply may be rejected. 48 | 49 | Thank you for being part of this journey! Your contributions are what keep the project thriving and evolving. 🚀✨ 50 | -------------------------------------------------------------------------------- /PRIVACY-POLICY.md: -------------------------------------------------------------------------------- 1 | ### Privacy Policy for Telegram Sync 2 | 3 | #### Last updated: 18.10.2023 4 | 5 | Thank you for choosing to use Telegram Sync, an open-source plugin developed for the Obsidian platform. This Privacy Policy outlines our commitment to protecting your privacy and personal information when using our plugin. 6 | 7 | --- 8 | 9 | #### Introduction 10 | 11 | Telegram Sync is designed to transfer messages and files from Telegram to your Obsidian vault. This plugin is engineered with a dual-layered architecture, utilizing two types of connections with Telegram: Bot API and Client API. Both Bot API and Client API connections are highly secure, using Telegram's native MTProto encryption protocol to ensure the utmost safety of your data during transmission. MTProto is Telegram's proprietary encryption protocol that is designed to make your messages and files secure against a wide array of cybersecurity threats. It uses end-to-end encryption and has been examined for potential vulnerabilities by security experts. When employing Telegram Sync, all data flows are conducted within the boundaries of these secure API connections, making it a reliable and secure choice for your data transfer needs. Given the plugin's design, we can assure you that your data remains confidential and secure during its journey from Telegram to your Obsidian vault. 12 | 13 | --- 14 | 15 | #### Information We Collect 16 | 17 | As an open-source plugin, Telegram Sync does **not** collect any personal or non-personal data from its users. All data transfers occur solely between your Telegram account and your Obsidian vault. We do not store, share, or have access to any of this information. 18 | 19 | --- 20 | 21 | #### Data Transfer and Storage 22 | 23 | All data transferred by Telegram Sync are processed locally on your computer. Your Telegram messages and files are transferred directly to your Obsidian vault without any third-party intervention. 24 | There are no external servers that might compromise the security or confidentiality of your data. This direct approach allows for more secure and reliable communication between your Telegram account and Obsidian vault. The plugin essentially serves as an alternative Telegram client, similar to other third-party Telegram clients like Plus Messenger and Nekogram. 25 | 26 | --- 27 | 28 | #### User Authentication and Session Management 29 | 30 | If you choose to connect as a user, you'll go through Telegram's standard two-factor authentication process, and the plugin will register a new session within your Telegram account's list of devices. This ensures that your Telegram login credentials are verified, secure, and directly linked to your device or current location (IP address). All data, including session information, is stored locally on your machine, bolstering your data's safety. However, as the plugin connects to Telegram servers, it is advisable to secure your Telegram account with a strong, unique password and two-factor authentication. 31 | 32 | --- 33 | 34 | #### Data Deletion 35 | 36 | Telegram Sync offers the option to delete processed messages from Telegram once they have been transferred to your Obsidian vault. The deletion of these messages is entirely within your control and is not mandatory to use this plugin. 37 | 38 | --- 39 | 40 | #### Links to third-party websites and services 41 | 42 | The Plugin may contain links to other websites and online services (Telegram chat group, Telegram channel, Youtube channel, PayPal, NowPayments, KoFi, BuyMeACoffee, etc.). If you choose to click through to one of these other websites or online services, please note that any information you may provide will be subject to the privacy policy and other terms and conditions of that websites or service, and not to this Privacy Policy. We do not control third-party websites or services, and the fact that a link to such a website or service appears in the plugin does not mean that we endorse them or have approved their policies or practices relating to user information. Before providing any information to any third-party website or service, we encourage you to review the privacy policy and other terms and conditions of that website or service. You agree that we will have no liability for any matters relating to a third-party website or service that you provide information to, including their collection and handling of that information. 43 | 44 | #### Changes to this Privacy Policy 45 | 46 | We may update our Privacy Policy from time to time. We will notify you of any changes by updating the "Last updated" date of this Privacy Policy. You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page. 47 | 48 | --- 49 | 50 | #### Contact Us 51 | 52 | If you have any questions or suggestions about this Privacy Policy, do not hesitate to contact [support](https://t.me/ObsidianTelegramSync). 53 | 54 | --- 55 | 56 | By using Telegram Sync, you agree to the terms laid out in this Privacy Policy. Thank you for your trust and for using Telegram Sync. 57 | 58 | --- 59 | 60 | This Privacy Policy is compliant with relevant laws and regulations. Use of Telegram Sync signifies your agreement to this Privacy Policy and any subsequent updates. 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Telegram Sync for Obsidian 2 | 3 | 4 | 5 |   6 | 7 |   8 | 9 |   10 | 11 |

12 | 13 | Transfer messages and files from [Telegram](https://telegram.org/) to your [Obsidian](https://obsidian.md/plugins?id=telegram-sync) vault. You can easily save text, voice transcripts, images, and other files from your Telegram chats to Obsidian for further processing and organization. This plugin is only available for desktops and would never be available on mobile platforms. 14 | 15 | image image 16 | 17 | --- 18 | 19 | ## 📚 Table of Contents 20 | 21 | - [Features](#-features) 22 | - [Installation](#-installation) 23 | - [Manual Installation](#-manual-installation) 24 | - [Usage](#-usage) 25 | - [Supporters & Donations](#-supporters--donations) 26 | - [Contributing](#-contributing) 27 | 28 | ## 🚀 Features 29 | 30 | - Synchronize text messages and files 31 | - Save messages as individual notes or append to an existing note 32 | - Transcript voices and video notes (for Telegram Premium subscribers only) 33 | - Use customizable templates for new notes 34 | - Set folders for new notes and files 35 | - Automatically format text messages with markdown 36 | - Delete processed messages from Telegram 37 | 38 | ## 📦 Installation 39 | 40 | 1. Click on [Telegram Sync](https://obsidian.md/plugins?id=telegram-sync) link 41 | 2. Allow to open Obsidian app 42 | 3. Make sure that community plugins is turned on 43 | 4. Click the Install button, then enable the plugin by toggling the switch 44 | 45 | ## 👏 Manual Installation 46 | 47 | 1. Download main.js, styles.css, manifest.json from the [latest release](https://github.com/soberhacker/obsidian-telegram-sync/releases//latest) 48 | 2. Copy the downloaded files to /.obsidian/plugins/telegram-sync/ 49 | 3. Restart Obsidian and enable **Telegram Sync** in the Community plugins tab 50 | 51 | ## 📮 Usage 52 | 53 | 1. Create a new bot on Telegram by talking to the [@botFather](https://t.me/botfather) 54 | 2. Copy the bot token provided by the @botFather 55 | 3. Open the Obsidian settings and navigate to the **Telegram Sync** settings tab 56 | 4. Paste your bot token to **Bot > Connect > Bot Token** field 57 | 5. Configure the remaining settings according to your preferences 58 | 6. Start sending messages and files to your Telegram bot 59 | 7. When the Obsidian app is running on your laptop or PC, it syncs all your messages 60 | 8. You can optionally add your bot to any chats that you want to sync (the bot needs admin rights) 61 | 62 | ## 💁 Supporters & Donations 63 | 64 | Big thanks to everyone who's already been supporting this project - you rock! 65 | 66 | \- maauso - knopki - Fabio Scarsi - Volodymyr Tymoshchuk - Lina - Maks Rososhynskyi - anonymous patrons 67 | 68 | --- 69 | 70 |
71 | If you like this plugin and are considering donating 🌠 to support continued development, use the buttons below.

72 | 73 | 74 | 75 |    76 | 77 |
78 | 79 | 80 |    81 | 82 | 83 |
84 | 85 | --- 86 | 87 | ## 💻 Contributing 88 | 89 | If you're thinking about contributing, please check out the [Contributing Guide](./CONTRIBUTING.md) first. And a heartfelt thank you to everyone who has already contributed - your help is greatly appreciated! 90 |
91 | 92 |
93 | 94 | 95 | 96 |
97 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | 6 | | Version | Supported | 7 | | ------- | ------------------ | 8 | | 1.2.x | :white_check_mark: | 9 | | 1.1.x | :x: | 10 | | 1.0.x | :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | I take security seriously. If you discover a security vulnerability within my project, I would appreciate your help in disclosing it to us in a responsible manner. 15 | 16 | Please do the following: 17 | 18 | 1. Email me at sober.guruhacker@gmail.com with the subject "Vulnerability Report: [short description of the issue]" 19 | 2. Provide a detailed description of the vulnerability, including steps to reproduce it and any relevant technical information (e.g., exploit code, screenshots) 20 | 3. Allow us reasonable time to investigate and address the vulnerability before disclosing it publicly 21 | 22 | I will do my best to acknowledge receipt of your vulnerability report within 1 week and to provide regular updates about our progress. If you're curious about the status of your report, feel free to email me again. Please do not contact contributors or submit a public issue about the vulnerability. 23 | 24 | I appreciate your help in keeping my project and users safe! 25 | -------------------------------------------------------------------------------- /docs/Authorized User Features.md: -------------------------------------------------------------------------------- 1 | #### Authorized User Features 2 | 3 | ##### Telegram Premium subscribers 4 | 5 | ✅ voice and video notes transcription using template variable {{voiceTranscript}}
6 | ✅ increased files download speed
7 | ❌ translating messages (_not implemented_)
8 | 9 | ##### All Telegram User 10 | 11 | ✅ available to download files more than 50MB (telegram bot limit)
12 | ✅ reacting with "👍" instead of replying for marking messages as processed
13 | ✅ easy installing the latest published beta versions
14 | ✅ processing messages older than 24 hours if Obsidian wasn't running
15 | ✅ sending notes from Obsidian to Telegram
16 | ❌ getting messages from other bots in connected group chats (_not implemented_)
17 | ❌ automatically identifying renamed topic without using command /topicName (_not implemented_)
18 | -------------------------------------------------------------------------------- /docs/Bot Token Encryption.md: -------------------------------------------------------------------------------- 1 | #### PIN Code Bot Token Encryption 2 | 3 | ##### What type of encryption does the plugin use? 4 | 5 | The plugin uses AES-256 encryption to store the bot token securely. By default, the token is encrypted and saved locally on your device. However, since the plugin is open-source, the encryption key is embedded in the code, making it theoretically possible for other plugins to access the encrypted token. 6 | 7 | ##### What additional protection does the PIN code provide? 8 | 9 | Enabling PIN-based encryption means that the decryption process requires a user-defined PIN, which is not stored. The PIN exists only in the user’s memory, preventing other plugins from accessing it. This extra layer ensures that even if the encryption mechanism is known through the source code, only someone with the correct PIN can decrypt the bot token. 10 | 11 | ##### What risks does this encryption help prevent? 12 | 13 | - **Misuse of Bot Token**: Prevents scenarios where a malicious plugin could extract the token and use it for unauthorized actions, such as sending spam or other undesired activities through your bot. 14 | - **Bot Suspension**: Misuse of your bot token could lead to temporary or permanent suspension of your bot, making it unable to create new bots or send messages for a period (e.g., up to a month). 15 | 16 | ##### How does it work? 17 | 18 | When this feature is enabled, you will be prompted to enter your PIN each time Obsidian starts. This PIN is used to decrypt the bot token for the session, keeping the token secure while stored on your device. 19 | 20 | ##### What to do if you forget your PIN? 21 | 22 | If you forget your PIN, you will need to reset the encryption by re-entering your bot token in unencrypted form: 23 | 24 | 1. Open the plugin settings in Obsidian. 25 | 2. Enter your bot token without encryption. 26 | 3. Re-enable the encryption feature and set a new PIN. 27 | 28 | ##### Why is this important? 29 | 30 | Given the open-source nature of the plugin, adding a user-defined PIN helps ensure that your bot token remains under your control, even if other plugins attempt to access it. This feature is crucial for maintaining the integrity of your bot and avoiding unintended suspensions or breaches. 31 | -------------------------------------------------------------------------------- /docs/Message Objects Examples.md: -------------------------------------------------------------------------------- 1 | ###### example 1 2 | ```json 3 | { 4 | "message_id": 802, 5 | "from": { 6 | "id": 1110636370, 7 | "is_bot": false, 8 | "first_name": "soberHacker", 9 | "username": "soberhacker", 10 | "language_code": "en" 11 | }, 12 | "chat": { 13 | "id": 1110636370, 14 | "first_name": "soberHacker", 15 | "username": "soberhacker", 16 | "type": "private" 17 | }, 18 | "date": 1686431489, 19 | "reply_to_message": { 20 | "message_id": 676, 21 | "from": { 22 | "id": 1110636370, 23 | "is_bot": false, 24 | "first_name": "soberHacker", 25 | "username": "soberhacker", 26 | "language_code": "en" 27 | }, 28 | "chat": { 29 | "id": 1110636370, 30 | "first_name": "soberHacker", 31 | "username": "soberhacker", 32 | "type": "private" 33 | }, 34 | "date": 1686087817, 35 | "forward_from": { 36 | "id": 1189295682, 37 | "is_bot": false, 38 | "first_name": "Sober", 39 | "last_name": "Hacker", 40 | "username": "soberHacker" 41 | }, 42 | "forward_date": 1684404891, 43 | "text": "All is good?" 44 | }, 45 | "text": "Yes, I'm ok!" 46 | } 47 | ``` 48 | ###### example 2 (topic message) 49 | ```json 50 | { 51 | "message_id": 9, 52 | "from": { 53 | "id": 1110636370, 54 | "is_bot": false, 55 | "first_name": "soberHacker", 56 | "username": "soberhacker", 57 | "language_code": "en" 58 | }, 59 | "chat": { 60 | "id": -1001110672472, 61 | "title": "My Notes", 62 | "is_forum": true, 63 | "type": "supergroup" 64 | }, 65 | "date": 1686514218, 66 | "message_thread_id": 3, 67 | "reply_to_message": { 68 | "message_id": 3, 69 | "from": { 70 | "id": 1110636370, 71 | "is_bot": false, 72 | "first_name": "soberHacker", 73 | "username": "soberhacker", 74 | "language_code": "en" 75 | }, 76 | "chat": { 77 | "id": -1001110672472, 78 | "title": "My Notes", 79 | "is_forum": true, 80 | "type": "supergroup" 81 | }, 82 | "date": 1684966126, 83 | "message_thread_id": 3, 84 | "forum_topic_created": { 85 | "name": "Topic name", 86 | "icon_color": 13338331, 87 | "icon_custom_emoji_id": "5312241539987020022" 88 | }, 89 | "is_topic_message": true 90 | }, 91 | "text": "Text is good", 92 | "is_topic_message": true 93 | } 94 | ``` 95 | ###### example 3 (topic message without topic name) 96 | ```json 97 | { 98 | "message_id": 12, 99 | "from": { 100 | "id": 1110636370, 101 | "is_bot": false, 102 | "first_name": "soberHacker", 103 | "username": "soberhacker", 104 | "language_code": "en" 105 | }, 106 | "chat": { 107 | "id": -1001110672472, 108 | "title": "My Notes", 109 | "is_forum": true, 110 | "type": "supergroup" 111 | }, 112 | "date": 1686514910, 113 | "message_thread_id": 3, 114 | "reply_to_message": { 115 | "message_id": 6, 116 | "from": { 117 | "id": 1110636370, 118 | "is_bot": false, 119 | "first_name": "soberHacker", 120 | "username": "soberhacker", 121 | "language_code": "en" 122 | }, 123 | "chat": { 124 | "id": -1001110672472, 125 | "title": "My Notes", 126 | "is_forum": true, 127 | "type": "supergroup" 128 | }, 129 | "date": 1686514023, 130 | "message_thread_id": 3, 131 | "text": "This is message", 132 | "is_topic_message": true 133 | }, 134 | "text": "No, I'm message", 135 | "is_topic_message": true 136 | } 137 | ``` 138 | ###### example 3 (system message (bot deleted, added....)) 139 | ```json 140 | { 141 | "message_id": 6471, 142 | "from": { 143 | "id": 1110636370, 144 | "is_bot": false, 145 | "first_name": "soberhacker", 146 | "username": "soberhacker", 147 | "language_code": "en" 148 | }, 149 | "chat": { 150 | "id": -955999997, 151 | "title": "Test Group", 152 | "type": "group", 153 | "all_members_are_administrators": true 154 | }, 155 | "date": 1689804496, 156 | "group_chat_created": true 157 | } 158 | ``` 159 | ###### example 4 Channel Post 160 | ```json 161 | { 162 | "message_id": 10, 163 | "sender_chat": { 164 | "id": -1001909715512, 165 | "title": "Telegram Sync Test Channel", 166 | "type": "channel" 167 | }, 168 | "chat": { 169 | "id": -1001909715512, 170 | "title": "Telegram Sync Test Channel", 171 | "type": "channel" 172 | }, 173 | "date": 1691018159, 174 | "text": "ку" 175 | } 176 | ``` -------------------------------------------------------------------------------- /docs/Telegram Sync Insider Features.md: -------------------------------------------------------------------------------- 1 | #### Telegram Sync Insider Features 2 | 3 | ✅ complete list of new features
4 | ✅ vote for the next features to be developed
5 | ✅ processing messages older than 24 hours if Obsidian wasn't running
6 | ✅ easy installing the latest published beta versions
7 | ✅ plugin updates in the channel instead of informational messages in your bot
8 | ❌ processing messages from other bots in connected group chats (_not implemented_)
9 | ❌ posting notes as messages in selected Telegram chats (_not implemented_)
10 | 11 | **⚠ For all of these features, you must connect both your Telegram user and bot.** 12 | -------------------------------------------------------------------------------- /docs/Template Variables List.md: -------------------------------------------------------------------------------- 1 | ### Message Filter Variables 2 | 3 | ```ts 4 | {{all}} - all messages 5 | {{user=VALUE}} - messages from user with username, full name or id equal VALUE 6 | {{chat=VALUE}} - messages in bot | group | channel with name or id equal VALUE 7 | {{topic=VALUE}} - messages in topic with name VALUE 8 | {{forwardFrom=VALUE}} - messages forwarded from chat or user with name VALUE 9 | {{content~VALUE}} - messages contain text VALUE 10 | {{voiceTranscript~VALUE}} - voice transcripts contain text VALUE 11 | ``` 12 | 13 | #### Filter examples: 14 | 15 | ```js 16 | // messages from group "My Notes" in topic "Memes" will be selected 17 | {{chat=My Notes}}{{topic=Memes}} 18 | // messages that simultaneously have hash tags #video and #movie will be selected 19 | {{content~#video}}{{content~#movie}} 20 | ``` 21 | 22 | - AND is default operator between conditions 23 | - OR is default operator between rules 24 | - If **Message Filter** is unspecified, filter by default will be equal {{all}} 25 | - If filter by topic fails, try to update topic name manually by bot command [/topicName NAME]() 26 |

27 | 28 | ### Generic Template Variables 29 | 30 | ```ts 31 | {{content:XX}} - XX characters of the message text 32 | {{voiceTranscript:XX}} - XX characters of voice transcript 33 | {{chat}} - link to the chat (bot | group | channel) 34 | {{chatId}} - id of the chat (bot | group | channel) 35 | {{chat:name}} - name of the chat (bot | group | channel) 36 | {{topic}} - link to the topic 37 | {{topic:name}} - name of the topic 38 | {{topicId}} - head message id representing the topic 39 | {{messageId}} - message id 40 | {{replyMessageId}} - reply message id 41 | {{user}} - link to the user who sent the message 42 | {{user:name}} - username who sent the message 43 | {{user:fullName}} - full name who sent the message 44 | {{userId}} - id of the user who sent the message 45 | {{forwardFrom}} - link to the forwarded message or its creator (user | channel) 46 | {{forwardFrom:name}} - name of forwarded message creator 47 | {{messageDate:YYYYMMDD}} - date, when the message was sent 48 | {{messageTime:HHmmss}} - time, when the message was sent 49 | {{creationDate:YYYYMMDD}} - date, when the message was created 50 | {{creationTime:HHmmss}} - time, when the message was created 51 | {{hashtag:[X]}} - hashtag (without #) at number X in the message text 52 | ``` 53 | 54 | - All available formats for dates and time you can find in [Moment JS Docs](https://momentjs.com/docs/#/parsing/string-format/) 55 | - **Generic variables** can be used in the following content and path templates 56 | - If the topic name displays incorrect, set the name manually using bot command [/topicName NAME]() 57 |

58 | 59 | ### Note Content Variables 60 | 61 | ```ts 62 | {{content}} - forwarded from + file | image + message text 63 | {{content:text}} - only message text 64 | {{content:[X-Y]}} - all lines from line number X to Y, inclusive 65 | {{files}} - files | images ![]() 66 | {{files:links}} - links to files | images []() 67 | {{voiceTranscript}} - transcribing voice (video notes!) to text (same limits as for Telegram Premium subscribers) 68 | {{url1}} - first url from the message 69 | {{url1:previewYYY}} - first url preview with YYY pixels height (default 250) 70 | {{replace:TEXT=>WITH}} - replace or delete text in resulting note (\n - new line) 71 | ``` 72 | 73 | #### Note content template example: 74 | 75 | ``` 76 | {{messageDate:YYYY}} {{content:[1]}} 77 | 78 | {{content:[2]}} - the second line 79 | {{content:[2-]}} - from the second line to the last 80 | {{content:[-1]}} - the last line 81 | {{content:[2-4]}} - from the second to the fourth lines 82 | 83 | Source: {{chat}}-{{forwardFrom}} 84 | Created: {{creationDate:YYYY-DD-MM}} {{creationTime:HH:mm:ss}} 85 | {{replace:\n\n=>\n}} 86 | ``` 87 | 88 | - If **Template file** is unspecified, template by default will be equal {{content}} 89 |

90 | 91 | ### Note Path Variables 92 | 93 | ```ts 94 | {{content:XX}} - XX characters of the message text (max 100 characters) 95 | {{content:[X]}} - message line number X 96 | {{messageTime:YYYYMMDDHHmmssSSS}} - use full time format for creating unique note names 97 | ``` 98 | 99 | #### Note paths examples: 100 | 101 | ```js 102 | // A separate note is created for each message, because note names are based on message text and time 103 | Telegram/{{content:30}} - {{messageTime:YYYYMMDDHHmmssSSS}}.md 104 | // All message are appended to the note with name "Telegram.md" 105 | Telegram.md 106 | // All messages from one day are added to a daily note 107 | {{messageDate:YYYYMMDD}}.md 108 | // For every chat will be created separate folder 109 | myNotes/{{chat:name}}/{{forwardFrom:name}}/{{content:[1]}}.md 110 | // Messages are grouped by year, month, day and time 111 | myNotes/{{messageDate:YYYY/MM/DD}}/{{messageTime:HHmmssSSS}}.md 112 | ``` 113 | 114 | ```json 115 | ⚠️ If a note path template is empty, then notes will not be created 116 | ⚠️ If a note with such name exists then new data will be always appended to this note 117 | ``` 118 | 119 | ### File Path Variables 120 | 121 | ```ts 122 | {{file:type}} - file type identified by Telegram (video, audio, voice, photo, document) 123 | {{file:extension}} - file extension (mp3, ogg, docx, png...) 124 | {{file:name}} - file name assigned by Telegram (without extension) 125 | ``` 126 | 127 | #### File paths examples: 128 | 129 | ```js 130 | Telegram/{{file:type}}s/{{file:name}} - {{messageTime:YYYYMMDDHHmmssSSS}}.{{file:extension}} 131 | myFiles/{{forwardFrom:name}}_{{file:name}}_{{messageTime:YYYYMMDDHHmmssSSS}}.{{file:extension}} 132 | myFiles/{{messageDate:YYYY/MM/DD}}/{{file:type}}.{{messageTime:HHmmssSSS}}.{{file:name}}.{{file:extension}} 133 | ``` 134 | 135 | ```json 136 | ⚠️ If a file path template is empty then files will not be saved 137 | ⚠️ If a file already exists, ` - {{messageTime:YYYYMMDDHHmmssSSS}}` will be added to its name 138 | ``` 139 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = `/* 6 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 7 | if you want to view the source, please visit the github repository of this plugin 8 | */ 9 | `; 10 | 11 | const prod = process.argv[2] === "production"; 12 | const test = process.argv[2] === "test"; 13 | const mainPath = "main.js"; 14 | 15 | const context = await esbuild.context({ 16 | banner: { 17 | js: banner, 18 | }, 19 | entryPoints: ["src/main.ts"], 20 | bundle: true, 21 | external: [ 22 | "obsidian", 23 | "electron", 24 | "@codemirror/autocomplete", 25 | "@codemirror/collab", 26 | "@codemirror/commands", 27 | "@codemirror/language", 28 | "@codemirror/lint", 29 | "@codemirror/search", 30 | "@codemirror/state", 31 | "@codemirror/view", 32 | "@lezer/common", 33 | "@lezer/highlight", 34 | "@lezer/lr", 35 | ...builtins, 36 | ], 37 | format: "cjs", 38 | target: "es2018", 39 | logLevel: "info", 40 | sourcemap: prod ? false : "inline", 41 | treeShaking: true, 42 | outfile: mainPath, 43 | }); 44 | 45 | if (prod || test) { 46 | await context.rebuild(); 47 | process.exit(0); 48 | } else { 49 | await context.watch(); 50 | } 51 | -------------------------------------------------------------------------------- /install-plugin.mjs: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import manifest from "./manifest.json" assert { type: "json" }; 4 | import https from "https"; 5 | 6 | const destinationFolder = process.argv[2]; 7 | 8 | if (!destinationFolder) { 9 | console.error("Please provide path to Obsidian Vault"); 10 | process.exit(1); 11 | } 12 | 13 | const pluginsFolder = path.join(destinationFolder, ".obsidian", "plugins"); 14 | const thisPluginFolder = path.join(pluginsFolder, manifest.id); 15 | 16 | if (!fs.existsSync(thisPluginFolder)) { 17 | fs.mkdirSync(thisPluginFolder, { recursive: true }); 18 | } 19 | 20 | const filesToCopy = ["main.js", "styles.css", "manifest.json"]; 21 | 22 | filesToCopy.forEach((file) => { 23 | fs.copyFileSync(file, path.join(thisPluginFolder, file)); 24 | }); 25 | 26 | // install hot-reload plugin to automatically reload this plugin 27 | const hotReloadFilePath = path.join(thisPluginFolder, ".hotreload"); 28 | if (!fs.existsSync(hotReloadFilePath)) { 29 | fs.writeFileSync(hotReloadFilePath, ""); 30 | } 31 | const hotReloadFolder = path.join(pluginsFolder, "hot-reload"); 32 | 33 | if (!fs.existsSync(hotReloadFolder)) { 34 | fs.mkdirSync(hotReloadFolder, { recursive: true }); 35 | const hotReloadMain = "https://raw.githubusercontent.com/pjeby/hot-reload/master/main.js"; 36 | const hotReloadManifest = "https://raw.githubusercontent.com/pjeby/hot-reload/master/manifest.json"; 37 | https.get(hotReloadMain, function (response) { 38 | response.pipe(fs.createWriteStream(path.join(hotReloadFolder, "main.js"))); 39 | }); 40 | https.get(hotReloadManifest, function (response) { 41 | response.pipe(fs.createWriteStream(path.join(hotReloadFolder, "manifest.json"))); 42 | }); 43 | } 44 | 45 | console.log(`${new Date().toLocaleTimeString()} Plugin installed successfully!`); 46 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "telegram-sync", 3 | "name": "Telegram Sync", 4 | "version": "4.0.0", 5 | "minAppVersion": "1.0.0", 6 | "description": "Transfer messages and files from Telegram to Obsidian.", 7 | "author": "soberhacker", 8 | "authorUrl": "https://github.com/soberhacker/obsidian-telegram-sync", 9 | "fundingUrl": { 10 | "Buy me a coffee": "https://www.buymeacoffee.com/soberhacker", 11 | "PayPal": "https://www.paypal.com/donate/?hosted_button_id=VYSCUZX8MYGCU", 12 | "Ko-Fi": "https://ko-fi.com/soberhacker", 13 | "Cryptocurrency": "https://nowpayments.io/donation?api_key=JMM7NE1-M4X4JY6-N8EK1GJ-H8XQXFK" 14 | }, 15 | "isDesktopOnly": true 16 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-telegram-sync", 3 | "version": "4.0.0", 4 | "description": "Transfer messages and files from Telegram bot to Obsidian.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs && npm run post-build", 8 | "lint": "eslint src/**/*.ts", 9 | "lint-fix": "eslint --fix src/**/*.ts", 10 | "version": "node version-bump.mjs && git add manifest.json versions.json", 11 | "test": "node esbuild.config.mjs test && npm run post-build && node install-plugin.mjs", 12 | "release-notes-check": "node release-notes.mjs check", 13 | "build": "tsc -noEmit -skipLibCheck && npm run lint && node esbuild.config.mjs production && npm run post-build", 14 | "post-build": "node post-build.mjs" 15 | }, 16 | "keywords": [ 17 | "Telegram", 18 | "Syncing", 19 | "Messages", 20 | "Transfer Files" 21 | ], 22 | "author": "soberhacker", 23 | "license": "GNU Affero General Public License v3.0", 24 | "devDependencies": { 25 | "@types/async": "^3.2.18", 26 | "@types/linkify-it": "^3.0.2", 27 | "@types/mime-types": "^2.1.1", 28 | "@types/node": "^16.11.6", 29 | "@typescript-eslint/eslint-plugin": "5.29.0", 30 | "@typescript-eslint/parser": "5.29.0", 31 | "builtin-modules": "3.3.0", 32 | "esbuild": "0.17.3", 33 | "eslint": "^8.47.0", 34 | "eslint-config-prettier": "latest", 35 | "eslint-plugin-prettier": "latest", 36 | "obsidian": "latest", 37 | "prettier": "latest", 38 | "release-please": "^16.14.0", 39 | "tslib": "2.4.0", 40 | "typescript": "4.7.4" 41 | }, 42 | "dependencies": { 43 | "@popperjs/core": "^2.11.7", 44 | "@types/qrcode": "^1.5.0", 45 | "compare-versions": "^6.1.0", 46 | "linkify-it": "^4.0.1", 47 | "mime-types": "^2.1.35", 48 | "node-machine-id": "^1.1.12", 49 | "node-telegram-bot-api": "^0.66.0", 50 | "qrcode": "^1.5.3", 51 | "telegram": "^2.25.11" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /post-build.mjs: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | const mainPath = "main.js"; 4 | 5 | fs.readFile(mainPath, "utf8", function (err, data) { 6 | if (err) { 7 | return console.log(err); 8 | } 9 | var result = data.replace('require("punycode/")', 'require("punycode")'); 10 | 11 | fs.writeFile(mainPath, result, "utf8", function (err) { 12 | if (err) return console.log(err); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /release-notes.mjs: -------------------------------------------------------------------------------- 1 | // TODO translating messages 2 | // TODO notify in setting tab and bottom panel that new beta version is ready for installing 3 | // TODO add messagesLeftCnt displaying in status bar 4 | // TODO NEXT: sending notes from Obsidian to Telegram 5 | // TODO MED: "delete messages from Telegram" settings for each distribution rules 6 | // TODO NEXT: save files if no template file 7 | // TODO NEXT: check reconnecting 8 | // TODO NEXT: bur in reconnecting on MacBook https://t.me/sm1rnov_id 9 | import { compareVersions } from "compare-versions"; 10 | export const releaseVersion = "4.0.0"; 11 | export const showNewFeatures = true; 12 | export let showBreakingChanges = true; 13 | 14 | const newFeatures = `In this release, security has been enhanced by encrypting the bot token`; 15 | export const breakingChanges = `⚠️ Breaking changes!\n\nBot token may need to be re-entered. ⚠️`; 16 | export const telegramChannelLink = "https://t.me/obsidian_telegram_sync"; 17 | export const insiderFeaturesLink = 18 | "https://github.com/soberhacker/obsidian-telegram-sync/blob/main/docs/Telegram%20Sync%20Insider%20Features.md"; 19 | const telegramChannelAHref = `channel`; 20 | const insiderFeaturesAHref = `insider features`; 21 | const telegramChannelIntroduction = `Subscribe for free to the plugin's ${telegramChannelAHref} and enjoy access to ${insiderFeaturesAHref} and the latest beta versions, several months ahead of public release.`; 22 | const telegramChatLink = "chat"; 23 | const telegramChatIntroduction = `Join the plugin's ${telegramChatLink} - your space to seek advice, ask questions, and share knowledge (access via the tribute bot).`; 24 | const donation = `If you appreciate this plugin and would like to support its continued development, please consider donating through the buttons below or via Telegram Stars in the ${telegramChannelAHref}!`; 25 | const bestRegards = "Best regards,\nYour soberhacker🍃🧘💻\n⌞"; 26 | 27 | export const privacyPolicyLink = "https://github.com/soberhacker/obsidian-telegram-sync/blob/main/PRIVACY-POLICY.md"; 28 | 29 | export const notes = ` 30 | Telegram Sync ${releaseVersion}\n 31 | 🆕 ${newFeatures}\n 32 | 💡 ${telegramChannelIntroduction}\n 33 | 💬 ${telegramChatIntroduction}\n 34 | 🦄 ${donation}\n 35 | ${bestRegards}`; 36 | 37 | export function showBreakingChangesInReleaseNotes() { 38 | showBreakingChanges = true; 39 | } 40 | 41 | export function versionALessThanVersionB(versionA, versionB) { 42 | if (!versionA || !versionB) return undefined; 43 | return compareVersions(versionA, versionB) == -1; 44 | } 45 | 46 | const check = process.argv[2] === "check"; 47 | 48 | if (check) { 49 | const packageVersion = process.env.npm_package_version; 50 | 51 | if (packageVersion !== releaseVersion) { 52 | console.error(`Failed! Release notes are outdated! ${packageVersion} !== ${releaseVersion}`); 53 | process.exit(1); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ConnectionStatusIndicator.ts: -------------------------------------------------------------------------------- 1 | import { setIcon } from "obsidian"; 2 | import TelegramSyncPlugin from "./main"; 3 | 4 | export const connectionStatusIndicatorSettingName = "Connection status indicator"; 5 | export enum ConnectionStatusIndicatorType { 6 | HIDDEN = "never show, log the errors", 7 | CONSTANT = "show constantly all states", 8 | ONLY_WHEN_ERRORS = "show only when connection errors", 9 | } 10 | export type KeysOfConnectionStatusIndicatorType = keyof typeof ConnectionStatusIndicatorType; 11 | export const checkConnectionMessage = 12 | "Check internet (proxy) connection, the functionality of Telegram using the official app. If everything is ok, restart Obsidian."; 13 | 14 | export default class ConnectionStatusIndicator { 15 | plugin: TelegramSyncPlugin; 16 | icon?: HTMLElement; 17 | label?: HTMLLabelElement; 18 | 19 | constructor(plugin: TelegramSyncPlugin) { 20 | this.plugin = plugin; 21 | } 22 | 23 | private create() { 24 | if (this.icon) return; // status icon resource has already been allocated 25 | this.icon = this.plugin.addStatusBarItem(); 26 | this.icon.id = "connection-status-indicator"; 27 | setIcon(this.icon, "send"); 28 | this.label = this.icon.createEl("label"); 29 | this.label.setAttr("for", "connection-status-indicator"); 30 | } 31 | 32 | destroy() { 33 | this.label?.remove(); 34 | this.icon?.remove(); 35 | this.icon = undefined; 36 | this.label = undefined; 37 | } 38 | 39 | update(error?: Error) { 40 | if ( 41 | this.plugin.settings.connectionStatusIndicatorType == "HIDDEN" || 42 | (this.plugin.settings.connectionStatusIndicatorType == "ONLY_WHEN_ERRORS" && !error) 43 | ) { 44 | this.destroy(); 45 | return; 46 | } 47 | this.create(); 48 | 49 | if (this.plugin.isBotConnected()) this.setConnected(); 50 | else this.setDisconnected(error?.message); 51 | } 52 | 53 | private setConnected() { 54 | if (!this.icon) return; 55 | this.label?.setText(""); 56 | this.label?.removeAttribute("style"); 57 | this.icon.removeAttribute("data-tooltip-position"); 58 | this.icon.removeAttribute("aria-label"); 59 | } 60 | 61 | private setDisconnected(error?: string): void { 62 | if (!this.icon) return; 63 | this.icon.setAttrs({ 64 | "data-tooltip-position": "top", 65 | "aria-label": `${error || ""}\n${checkConnectionMessage}`.trimStart(), 66 | }); 67 | this.label?.setAttr("style", "position: relative; left: -3px; bottom: -3px; font-weight: bold; color:red;"); 68 | this.label?.setText("x"); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | import { DEFAULT_SETTINGS, TelegramSyncSettings, TelegramSyncSettingTab } from "./settings/Settings"; 3 | import TelegramBot from "node-telegram-bot-api"; 4 | import { machineIdSync } from "node-machine-id"; 5 | import { 6 | _15sec, 7 | _2min, 8 | displayAndLog, 9 | StatusMessages, 10 | displayAndLogError, 11 | hideMTProtoAlerts, 12 | _1sec, 13 | _5sec, 14 | _day, 15 | } from "./utils/logUtils"; 16 | import * as Client from "./telegram/user/client"; 17 | import * as Bot from "./telegram/bot/bot"; 18 | import * as User from "./telegram/user/user"; 19 | import { enqueue } from "./utils/queues"; 20 | import { clearTooManyRequestsInterval } from "./telegram/bot/tooManyRequests"; 21 | import { clearCachedMessagesInterval } from "./telegram/convertors/botMessageToClientMessage"; 22 | import { clearHandleMediaGroupInterval } from "./telegram/bot/message/handlers"; 23 | import ConnectionStatusIndicator, { checkConnectionMessage } from "./ConnectionStatusIndicator"; 24 | import { mainDeviceIdSettingName } from "./settings/modals/BotSettings"; 25 | import { 26 | createDefaultMessageDistributionRule, 27 | createDefaultMessageFilterCondition, 28 | defaultFileNameTemplate, 29 | defaultMessageFilterQuery, 30 | defaultNoteNameTemplate, 31 | defaultTelegramFolder, 32 | } from "./settings/messageDistribution"; 33 | import os from "os"; 34 | import { clearCachedUnprocessedMessages, forwardUnprocessedMessages } from "./telegram/user/sync"; 35 | import { decrypt, encrypt } from "./utils/crypto256"; 36 | import { PinCodeModal } from "./settings/modals/PinCode"; 37 | 38 | // TODO LOW: add "connecting" 39 | export type ConnectionStatus = "connected" | "disconnected"; 40 | export type PluginStatus = "unloading" | "unloaded" | "loading" | "loaded"; 41 | 42 | // Main class for the Telegram Sync plugin 43 | export default class TelegramSyncPlugin extends Plugin { 44 | settings: TelegramSyncSettings; 45 | settingsTab?: TelegramSyncSettingTab; 46 | private botStatus: ConnectionStatus = "disconnected"; 47 | // TODO LOW: change to userStatus and display in status bar 48 | userConnected = false; 49 | checkingBotConnection = false; 50 | checkingUserConnection = false; 51 | // TODO LOW: TelegramSyncBot extends TelegramBot 52 | bot?: TelegramBot; 53 | botUser?: TelegramBot.User; 54 | createdFilePaths: string[] = []; 55 | currentDeviceId = machineIdSync(true); 56 | lastPollingErrors: string[] = []; 57 | restartingIntervalId?: NodeJS.Timer; 58 | restartingIntervalTime = _15sec; 59 | messagesLeftCnt = 0; 60 | connectionStatusIndicator? = new ConnectionStatusIndicator(this); 61 | status: PluginStatus = "loading"; 62 | time4processOldMessages = false; 63 | processOldMessagesIntervalId?: NodeJS.Timer; 64 | pinCode?: string = undefined; 65 | 66 | async initTelegram(initType?: Client.SessionType) { 67 | this.lastPollingErrors = []; 68 | this.messagesLeftCnt = 0; 69 | if (this.settings.mainDeviceId && this.settings.mainDeviceId !== this.currentDeviceId) { 70 | this.stopTelegram(); 71 | displayAndLog( 72 | this, 73 | `Paused on this device. If you want the plugin to work here, change the value of "${mainDeviceIdSettingName}" to the current device id in the bot settings.`, 74 | 0, 75 | ); 76 | return; 77 | } 78 | // Uncomment timeout to debug if test during plugin loading 79 | // await new Promise((resolve) => setTimeout(resolve, 3000)); 80 | 81 | if (!initType || initType == "user") 82 | await User.connect(this, this.settings.telegramSessionType, this.settings.telegramSessionId); 83 | 84 | if (!initType || initType == "bot") await Bot.connect(this); 85 | 86 | // restart telegram bot or user if needed 87 | if (!this.restartingIntervalId) this.setRestartTelegramInterval(this.restartingIntervalTime); 88 | 89 | // start processing old messages 90 | if (!this.processOldMessagesIntervalId) { 91 | this.setProcessOldMessagesInterval(); 92 | this.time4processOldMessages = true; 93 | await this.processOldMessages(); 94 | } 95 | } 96 | 97 | setRestartTelegramInterval(newRestartingIntervalTime: number, sessionType?: Client.SessionType) { 98 | this.restartingIntervalTime = newRestartingIntervalTime; 99 | clearInterval(this.restartingIntervalId); 100 | this.restartingIntervalId = setInterval( 101 | async () => await enqueue(this, this.restartTelegram, sessionType), 102 | this.restartingIntervalTime, 103 | ); 104 | } 105 | 106 | setProcessOldMessagesInterval() { 107 | this.clearProcessOldMessagesInterval(); 108 | this.processOldMessagesIntervalId = setInterval(async () => { 109 | this.time4processOldMessages = true; 110 | await enqueue(this, this.processOldMessages); 111 | }, _day); 112 | } 113 | 114 | clearProcessOldMessagesInterval() { 115 | clearInterval(this.processOldMessagesIntervalId); 116 | this.processOldMessagesIntervalId = undefined; 117 | this.time4processOldMessages = false; 118 | } 119 | 120 | async restartTelegram(sessionType?: Client.SessionType) { 121 | let needRestartInterval = false; 122 | try { 123 | if ( 124 | (!sessionType || sessionType == "user") && 125 | !this.userConnected && 126 | !this.checkingUserConnection && 127 | this.settings.telegramSessionType == "user" 128 | ) { 129 | await this.initTelegram("user"); 130 | needRestartInterval = true; 131 | } 132 | 133 | if ( 134 | (!sessionType || sessionType == "bot") && 135 | !this.isBotConnected() && 136 | !this.checkingBotConnection && 137 | this.settings?.botToken 138 | ) { 139 | await this.initTelegram("bot"); 140 | needRestartInterval = true; 141 | } 142 | 143 | if (needRestartInterval) this.setRestartTelegramInterval(_15sec); 144 | else if (this.bot && !sessionType && os.type() == "Darwin" && this.isBotConnected()) { 145 | try { 146 | this.botUser = await this.bot.getMe(); 147 | } catch { 148 | this.setBotStatus("disconnected"); 149 | this.userConnected = false; 150 | } 151 | } 152 | } catch { 153 | this.setRestartTelegramInterval( 154 | this.restartingIntervalTime < _2min ? this.restartingIntervalTime * 2 : this.restartingIntervalTime, 155 | ); 156 | } 157 | } 158 | 159 | async processOldMessages() { 160 | if (!this.time4processOldMessages) return; 161 | if (!this.settings.processOldMessages) clearCachedUnprocessedMessages(); 162 | if (!this.userConnected || !this.settings.processOldMessages || !this.botUser) return; 163 | try { 164 | await forwardUnprocessedMessages(this); 165 | } finally { 166 | this.time4processOldMessages = false; 167 | } 168 | } 169 | 170 | stopTelegram() { 171 | this.checkingBotConnection = false; 172 | this.checkingUserConnection = false; 173 | this.clearProcessOldMessagesInterval(); 174 | clearInterval(this.restartingIntervalId); 175 | this.restartingIntervalId = undefined; 176 | Bot.disconnect(this); 177 | User.disconnect(this); 178 | } 179 | 180 | // Load the plugin, settings, and initialize the bot 181 | async onload() { 182 | this.status = "loading"; 183 | 184 | await this.loadSettings(); 185 | await this.upgradeSettings(); 186 | 187 | // Add a settings tab for this plugin 188 | this.settingsTab = new TelegramSyncSettingTab(this.app, this); 189 | this.addSettingTab(this.settingsTab); 190 | 191 | hideMTProtoAlerts(this); 192 | // Initialize the Telegram bot when Obsidian layout is fully loaded 193 | this.app.workspace.onLayoutReady(async () => { 194 | enqueue(this, this.initTelegram); 195 | }); 196 | 197 | this.status = "loaded"; 198 | displayAndLog(this, this.status, 0); 199 | } 200 | 201 | async onunload(): Promise { 202 | this.status = "unloading"; 203 | try { 204 | clearTooManyRequestsInterval(); 205 | clearCachedMessagesInterval(); 206 | clearHandleMediaGroupInterval(); 207 | this.connectionStatusIndicator?.destroy(); 208 | this.connectionStatusIndicator = undefined; 209 | this.settingsTab = undefined; 210 | this.stopTelegram(); 211 | } catch (e) { 212 | displayAndLog(this, e, 0); 213 | } finally { 214 | this.status = "unloaded"; 215 | displayAndLog(this, this.status, 0); 216 | } 217 | } 218 | 219 | // Load settings from the plugin's data 220 | async loadSettings() { 221 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 222 | } 223 | 224 | // Save settings to the plugin's data 225 | async saveSettings() { 226 | await this.saveData(this.settings); 227 | } 228 | 229 | async upgradeSettings() { 230 | let needToSaveSettings = false; 231 | if (this.settings.cacheCleanupAtStartup) { 232 | localStorage.removeItem("GramJs:apiCache"); 233 | this.settings.cacheCleanupAtStartup = false; 234 | needToSaveSettings = true; 235 | } 236 | 237 | if (this.settings.messageDistributionRules.length == 0) { 238 | this.settings.messageDistributionRules.push(createDefaultMessageDistributionRule()); 239 | needToSaveSettings = true; 240 | } else { 241 | // fixing incorrectly saved rules 242 | this.settings.messageDistributionRules.forEach((rule) => { 243 | if (!rule.messageFilterQuery || !rule.messageFilterConditions) { 244 | rule.messageFilterQuery = defaultMessageFilterQuery; 245 | rule.messageFilterConditions = [createDefaultMessageFilterCondition()]; 246 | needToSaveSettings = true; 247 | } 248 | if (!rule.filePathTemplate && !rule.notePathTemplate && !rule.templateFilePath) { 249 | rule.notePathTemplate = `${defaultTelegramFolder}/${defaultNoteNameTemplate}`; 250 | rule.filePathTemplate = `${defaultTelegramFolder}/${defaultFileNameTemplate}`; 251 | needToSaveSettings = true; 252 | } 253 | }); 254 | } 255 | 256 | if (!this.settings.botTokenEncrypted) { 257 | this.botTokenEncrypt(); 258 | needToSaveSettings = true; 259 | } 260 | 261 | needToSaveSettings && (await this.saveSettings()); 262 | } 263 | 264 | async getBotUser(): Promise { 265 | this.botUser = this.botUser || (await this.bot?.getMe()); 266 | if (!this.botUser) throw new Error("Can't get access to bot info. Restart the Telegram Sync plugin"); 267 | return this.botUser; 268 | } 269 | 270 | isBotConnected(): boolean { 271 | return this.botStatus === "connected"; 272 | } 273 | 274 | async setBotStatus(status: ConnectionStatus, error?: Error) { 275 | if (this.botStatus == status && !error) return; 276 | 277 | this.botStatus = status; 278 | this.connectionStatusIndicator?.update(error); 279 | 280 | if (this.isBotConnected()) displayAndLog(this, StatusMessages.BOT_CONNECTED, 0); 281 | else if (!error) displayAndLog(this, StatusMessages.BOT_DISCONNECTED, 0); 282 | else displayAndLogError(this, error, StatusMessages.BOT_DISCONNECTED, checkConnectionMessage, undefined, 0); 283 | } 284 | 285 | async getBotToken(): Promise { 286 | if (!this.settings.botTokenEncrypted) return this.settings.botToken; 287 | 288 | if (this.settings.encryptionByPinCode && !this.pinCode) { 289 | await new Promise((resolve) => { 290 | const pinCodeModal = new PinCodeModal(this, true); 291 | pinCodeModal.onClose = async () => { 292 | if (!this.pinCode) displayAndLog(this, "Plugin Telegram Sync stopped. No pin code entered."); 293 | resolve(undefined); 294 | }; 295 | pinCodeModal.open(); 296 | }); 297 | } 298 | return decrypt(this.settings.botToken, this.pinCode); 299 | } 300 | 301 | botTokenEncrypt(saveSettings = false) { 302 | this.settings.botToken = encrypt(this.settings.botToken, this.pinCode); 303 | this.settings.botTokenEncrypted = true; 304 | saveSettings && this.saveSettings(); 305 | displayAndLog(this, "Bot token encrypted", 0); 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/settings/messageDistribution.ts: -------------------------------------------------------------------------------- 1 | export enum ConditionType { 2 | ALL = "all", 3 | USER = "user", 4 | CHAT = "chat", 5 | TOPIC = "topic", 6 | FORWARD_FROM = "forwardFrom", 7 | CONTENT = "content", 8 | VOICE_TRANSCRIPT = "voiceTranscript", 9 | } 10 | 11 | enum ConditionOperation { 12 | EQUAL = "=", 13 | NOT_EQUAL = "!=", 14 | CONTAIN = "~", 15 | NOT_CONTAIN = "!~", 16 | NO_OPERATION = "", 17 | } 18 | 19 | export interface MessageFilterCondition { 20 | conditionType: ConditionType; 21 | operation: ConditionOperation; 22 | value: string; 23 | } 24 | 25 | export interface MessageDistributionRuleInfo { 26 | name: string; 27 | description: string; 28 | } 29 | 30 | export const defaultMessageFilterQuery = `{{${ConditionType.ALL}}}`; 31 | 32 | export function createDefaultMessageFilterCondition(): MessageFilterCondition { 33 | return { 34 | conditionType: ConditionType.ALL, 35 | operation: ConditionOperation.NO_OPERATION, 36 | value: "", 37 | }; 38 | } 39 | 40 | export interface MessageDistributionRule { 41 | messageFilterQuery: string; 42 | messageFilterConditions: MessageFilterCondition[]; 43 | templateFilePath: string; 44 | notePathTemplate: string; 45 | filePathTemplate: string; 46 | reversedOrder: boolean; 47 | heading: string; 48 | } 49 | 50 | export const defaultTelegramFolder = "Telegram"; 51 | export const defaultNoteNameTemplate = "{{content:30}} - {{messageTime:YYYYMMDDHHmmssSSS}}.md"; 52 | export const defaultFileNameTemplate = "{{file:name}} - {{messageTime:YYYYMMDDHHmmssSSS}}.{{file:extension}}"; 53 | 54 | export function createDefaultMessageDistributionRule(): MessageDistributionRule { 55 | return { 56 | messageFilterQuery: defaultMessageFilterQuery, 57 | messageFilterConditions: [createDefaultMessageFilterCondition()], 58 | templateFilePath: "", 59 | notePathTemplate: `${defaultTelegramFolder}/${defaultNoteNameTemplate}`, 60 | filePathTemplate: `${defaultTelegramFolder}/{{file:type}}s/${defaultFileNameTemplate}`, 61 | reversedOrder: false, 62 | heading: "", 63 | }; 64 | } 65 | 66 | export function createBlankMessageDistributionRule(): MessageDistributionRule { 67 | return { 68 | messageFilterQuery: "", 69 | messageFilterConditions: [], 70 | templateFilePath: "", 71 | notePathTemplate: "", 72 | filePathTemplate: "", 73 | reversedOrder: false, 74 | heading: "", 75 | }; 76 | } 77 | 78 | export function extractConditionsFromFilterQuery(messageFilterQuery: string): MessageFilterCondition[] { 79 | if (!messageFilterQuery || messageFilterQuery == `{{${ConditionType.ALL}}}`) 80 | return [ 81 | { 82 | conditionType: ConditionType.ALL, 83 | operation: ConditionOperation.NO_OPERATION, 84 | value: "", 85 | }, 86 | ]; 87 | const filterQueryPattern = /\{{([^{}=!~]+)(=|!=|~|!~)([^{}]+)\}}/g; 88 | const matches = [...messageFilterQuery.matchAll(filterQueryPattern)]; 89 | 90 | // Check for unbalanced braces 91 | const openBracesCount = (messageFilterQuery.match(/\{{/g) || []).length; 92 | const closeBracesCount = (messageFilterQuery.match(/\}}/g) || []).length; 93 | if (openBracesCount !== closeBracesCount) { 94 | throw new Error("Unbalanced braces in filter query."); 95 | } 96 | 97 | return matches.map((match) => { 98 | const [, conditionType, operation, value] = match; 99 | 100 | if (!value) { 101 | throw new Error(`Empty value for condition type: ${conditionType}`); 102 | } 103 | 104 | if (!Object.values(ConditionType).includes(conditionType as ConditionType)) { 105 | throw new Error(`Unknown condition type: ${conditionType}`); 106 | } 107 | 108 | if (!Object.values(ConditionOperation).includes(operation as ConditionOperation)) { 109 | throw new Error(`Unsupported filter operation: ${operation}`); 110 | } 111 | 112 | return { 113 | conditionType: conditionType as ConditionType, 114 | operation: operation as ConditionOperation, 115 | value: value, 116 | }; 117 | }); 118 | } 119 | 120 | export function getMessageDistributionRuleInfo(distributionRule: MessageDistributionRule): MessageDistributionRuleInfo { 121 | const messageDistributionRuleInfo: MessageDistributionRuleInfo = { name: "", description: "" }; 122 | if (distributionRule.notePathTemplate) 123 | messageDistributionRuleInfo.description = `Note path: ${distributionRule.notePathTemplate}`; 124 | else if (distributionRule.templateFilePath) 125 | messageDistributionRuleInfo.description = `Template file: ${distributionRule.templateFilePath}`; 126 | else if (distributionRule.filePathTemplate) 127 | messageDistributionRuleInfo.description = `File path: ${distributionRule.filePathTemplate}`; 128 | if (!distributionRule.messageFilterConditions || distributionRule.messageFilterConditions.length == 0) { 129 | messageDistributionRuleInfo.name = "error: wrong filter query!"; 130 | return messageDistributionRuleInfo; 131 | } 132 | 133 | for (const condition of distributionRule.messageFilterConditions) { 134 | if (condition.conditionType == ConditionType.ALL) { 135 | messageDistributionRuleInfo.name = `filter = "all messages"`; 136 | return messageDistributionRuleInfo; 137 | } 138 | messageDistributionRuleInfo.name = 139 | messageDistributionRuleInfo.name + 140 | `${condition.conditionType} ${condition.operation} "${condition.value}" & `; 141 | } 142 | if (messageDistributionRuleInfo.name.length > 50) 143 | messageDistributionRuleInfo.name = messageDistributionRuleInfo.name.slice(0, 50) + "..."; 144 | else messageDistributionRuleInfo.name = messageDistributionRuleInfo.name.replace(/ & $/, ""); 145 | return messageDistributionRuleInfo; 146 | } 147 | -------------------------------------------------------------------------------- /src/settings/modals/AdvancedSettings.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting } from "obsidian"; 2 | import TelegramSyncPlugin from "src/main"; 3 | import { _5sec } from "src/utils/logUtils"; 4 | import { 5 | ConnectionStatusIndicatorType, 6 | KeysOfConnectionStatusIndicatorType, 7 | connectionStatusIndicatorSettingName, 8 | } from "src/ConnectionStatusIndicator"; 9 | 10 | export class AdvancedSettingsModal extends Modal { 11 | advancedSettingsDiv: HTMLDivElement; 12 | saved = false; 13 | constructor(public plugin: TelegramSyncPlugin) { 14 | super(plugin.app); 15 | } 16 | 17 | async display() { 18 | this.addHeader(); 19 | 20 | this.addConnectionStatusIndicator(); 21 | this.addDeleteMessagesFromTelegram(); 22 | this.addMessageDelimiterSetting(); 23 | this.addParallelMessageProcessing(); 24 | } 25 | 26 | addHeader() { 27 | this.contentEl.empty(); 28 | this.advancedSettingsDiv = this.contentEl.createDiv(); 29 | this.titleEl.setText("Advanced settings"); 30 | } 31 | 32 | addMessageDelimiterSetting() { 33 | new Setting(this.advancedSettingsDiv) 34 | .setName(`Default delimiter "***" between messages`) 35 | .setDesc("Turn off for using a custom delimiter, which you can set in the template file") 36 | .addToggle((toggle) => { 37 | toggle.setValue(this.plugin.settings.defaultMessageDelimiter); 38 | toggle.onChange(async (value) => { 39 | this.plugin.settings.defaultMessageDelimiter = value; 40 | await this.plugin.saveSettings(); 41 | }); 42 | }); 43 | } 44 | 45 | addParallelMessageProcessing() { 46 | new Setting(this.advancedSettingsDiv) 47 | .setName(`Parallel message processing`) 48 | .setDesc("Turn on for faster message and file processing. Caution: may disrupt message order") 49 | .addToggle((toggle) => { 50 | toggle.setValue(this.plugin.settings.parallelMessageProcessing); 51 | toggle.onChange(async (value) => { 52 | this.plugin.settings.parallelMessageProcessing = value; 53 | await this.plugin.saveSettings(); 54 | }); 55 | }); 56 | } 57 | 58 | addConnectionStatusIndicator() { 59 | new Setting(this.advancedSettingsDiv) 60 | .setName(connectionStatusIndicatorSettingName) 61 | .setDesc("Choose when you want to see the connection status indicator") 62 | .addDropdown((dropDown) => { 63 | dropDown.addOptions(ConnectionStatusIndicatorType); 64 | dropDown.setValue(this.plugin.settings.connectionStatusIndicatorType); 65 | dropDown.onChange(async (value) => { 66 | this.plugin.settings.connectionStatusIndicatorType = value as KeysOfConnectionStatusIndicatorType; 67 | this.plugin.connectionStatusIndicator?.update(); 68 | await this.plugin.saveSettings(); 69 | }); 70 | }); 71 | } 72 | 73 | addDeleteMessagesFromTelegram() { 74 | new Setting(this.advancedSettingsDiv) 75 | .setName("Delete messages from Telegram") 76 | .setDesc( 77 | "The Telegram messages will be deleted after processing them. If disabled, the Telegram messages will be marked as processed", 78 | ) 79 | .addToggle((toggle) => { 80 | toggle.setValue(this.plugin.settings.deleteMessagesFromTelegram); 81 | toggle.onChange(async (value) => { 82 | this.plugin.settings.deleteMessagesFromTelegram = value; 83 | await this.plugin.saveSettings(); 84 | }); 85 | }); 86 | } 87 | 88 | onOpen() { 89 | this.display(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/settings/modals/BotSettings.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting } from "obsidian"; 2 | import TelegramSyncPlugin from "src/main"; 3 | import { _5sec, displayAndLog } from "src/utils/logUtils"; 4 | import { PinCodeModal } from "./PinCode"; 5 | 6 | export const mainDeviceIdSettingName = "Main device id"; 7 | 8 | export class BotSettingsModal extends Modal { 9 | botSettingsDiv: HTMLDivElement; 10 | saved = false; 11 | constructor(public plugin: TelegramSyncPlugin) { 12 | super(plugin.app); 13 | } 14 | 15 | async display() { 16 | this.addHeader(); 17 | this.addBotToken(); 18 | this.addAllowedChatsSetting(); 19 | this.addDeviceId(); 20 | this.addEncryptionByPinCode(); 21 | this.addFooterButtons(); 22 | } 23 | 24 | addHeader() { 25 | this.contentEl.empty(); 26 | this.botSettingsDiv = this.contentEl.createDiv(); 27 | this.titleEl.setText("Bot settings"); 28 | const limitations = new Setting(this.botSettingsDiv).setDesc("⚠ Limitations of Telegram bot:"); 29 | const lim24Hours = document.createElement("div"); 30 | lim24Hours.setText("- It can get only messages sent within the last 24 hours"); 31 | lim24Hours.style.marginLeft = "10px"; 32 | const limBlocks = document.createElement("div"); 33 | limBlocks.style.marginLeft = "10px"; 34 | limBlocks.setText("- Use VPN or proxy to bypass blocks in China, Iran, and limited corporate networks "); 35 | limBlocks.createEl("a", { 36 | href: "https://github.com/soberhacker/obsidian-telegram-sync/issues/225#issuecomment-1780539957", 37 | text: "([ex. config of Clash],", 38 | }); 39 | limBlocks.createEl("a", { 40 | href: "https://github.com/windingblack/obsidian-global-proxy", 41 | text: " [Obsidian Global Proxy])", 42 | }); 43 | limitations.descEl.appendChild(lim24Hours); 44 | limitations.descEl.appendChild(limBlocks); 45 | } 46 | 47 | addBotToken() { 48 | new Setting(this.botSettingsDiv) 49 | .setName("Bot token (required)") 50 | .setDesc("Enter your Telegram bot token.") 51 | .addText(async (text) => { 52 | text.setPlaceholder("example: 6123456784:AAX9mXnFE2q9WahQ") 53 | .setValue(await this.plugin.getBotToken()) 54 | .onChange(async (value: string) => { 55 | if (!value) { 56 | text.inputEl.style.borderColor = "red"; 57 | text.inputEl.style.borderWidth = "2px"; 58 | text.inputEl.style.borderStyle = "solid"; 59 | } 60 | this.plugin.settings.botToken = value; 61 | this.plugin.settings.botTokenEncrypted = false; 62 | }); 63 | }); 64 | } 65 | 66 | addAllowedChatsSetting() { 67 | const allowedChatsSetting = new Setting(this.botSettingsDiv) 68 | .setName("Allowed chats (required)") 69 | .setDesc( 70 | "Enter list of usernames or chat ids that should be processed. At least your username must be entered.", 71 | ) 72 | .addTextArea((text) => { 73 | const textArea = text 74 | .setPlaceholder("example: soberhacker,1227636") 75 | .setValue(this.plugin.settings.allowedChats.join(", ")) 76 | .onChange(async (value: string) => { 77 | value = value.replace(/\s/g, ""); 78 | if (!value) { 79 | textArea.inputEl.style.borderColor = "red"; 80 | textArea.inputEl.style.borderWidth = "2px"; 81 | textArea.inputEl.style.borderStyle = "solid"; 82 | } 83 | this.plugin.settings.allowedChats = value.split(","); 84 | }); 85 | }); 86 | // add link to Telegram FAQ about getting username 87 | const howDoIGetUsername = document.createElement("div"); 88 | howDoIGetUsername.textContent = "To get help click on -> "; 89 | howDoIGetUsername.createEl("a", { 90 | href: "https://telegram.org/faq?setln=en#q-what-are-usernames-how-do-i-get-one", 91 | text: "Telegram FAQ", 92 | }); 93 | allowedChatsSetting.descEl.appendChild(howDoIGetUsername); 94 | } 95 | 96 | addDeviceId() { 97 | const deviceIdSetting = new Setting(this.botSettingsDiv) 98 | .setName(mainDeviceIdSettingName) 99 | .setDesc( 100 | "Specify the device to be used for sync when running Obsidian simultaneously on multiple desktops. If not specified, the priority will shift unpredictably.", 101 | ) 102 | .addText((text) => 103 | text 104 | .setPlaceholder("example: 98912984-c4e9-5ceb-8000-03882a0485e4") 105 | .setValue(this.plugin.settings.mainDeviceId) 106 | .onChange((value) => (this.plugin.settings.mainDeviceId = value)), 107 | ); 108 | 109 | // current device id copy to settings 110 | const deviceIdLink = deviceIdSetting.descEl.createDiv(); 111 | deviceIdLink.textContent = "To make the current device as main, click on -> "; 112 | deviceIdLink 113 | .createEl("a", { 114 | href: this.plugin.currentDeviceId, 115 | text: this.plugin.currentDeviceId, 116 | }) 117 | .onClickEvent((evt) => { 118 | evt.preventDefault(); 119 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 120 | let inputDeviceId: any; 121 | try { 122 | inputDeviceId = deviceIdSetting.controlEl.firstElementChild; 123 | inputDeviceId.value = this.plugin.currentDeviceId; 124 | } catch (error) { 125 | displayAndLog(this.plugin, `Try to copy and paste device id manually. Error: ${error}`, _5sec); 126 | } 127 | if (inputDeviceId && inputDeviceId.value) 128 | this.plugin.settings.mainDeviceId = this.plugin.currentDeviceId; 129 | }); 130 | } 131 | 132 | addEncryptionByPinCode() { 133 | const botTokenSetting = new Setting(this.botSettingsDiv) 134 | .setName("Bot token encryption using a PIN code") 135 | .setDesc( 136 | "Encrypt the bot token for enhanced security. When enabled, a PIN code is required at each Obsidian launch. ", 137 | ) 138 | .addToggle((toggle) => { 139 | toggle.setValue(this.plugin.settings.encryptionByPinCode); 140 | toggle.onChange(async (value) => { 141 | if (this.plugin.settings.botTokenEncrypted) { 142 | this.plugin.settings.botToken = await this.plugin.getBotToken(); 143 | this.plugin.settings.botTokenEncrypted = false; 144 | } 145 | this.plugin.settings.encryptionByPinCode = value; 146 | if (!value) { 147 | this.plugin.pinCode = undefined; 148 | return; 149 | } 150 | const pinCodeModal = new PinCodeModal(this.plugin, false); 151 | pinCodeModal.onClose = async () => { 152 | if (pinCodeModal.saved && this.plugin.pinCode) return; 153 | this.plugin.settings.encryptionByPinCode = false; 154 | }; 155 | pinCodeModal.open(); 156 | }); 157 | }); 158 | botTokenSetting.descEl.createEl("a", { 159 | href: "https://github.com/soberhacker/obsidian-telegram-sync/blob/main/docs/Bot%20Token%20Encryption.md", 160 | text: "What does this can prevent?", 161 | }); 162 | } 163 | 164 | addFooterButtons() { 165 | this.botSettingsDiv.createEl("br"); 166 | const footerButtons = new Setting(this.contentEl.createDiv()); 167 | footerButtons.addButton((b) => { 168 | b.setTooltip("Connect") 169 | .setIcon("checkmark") 170 | .onClick(async () => { 171 | if (!this.plugin.settings.botTokenEncrypted) this.plugin.botTokenEncrypt(); 172 | await this.plugin.saveSettings(); 173 | this.saved = true; 174 | this.close(); 175 | }); 176 | return b; 177 | }); 178 | footerButtons.addExtraButton((b) => { 179 | b.setIcon("cross") 180 | .setTooltip("Cancel") 181 | .onClick(async () => { 182 | await this.plugin.loadSettings(); 183 | this.saved = false; 184 | this.close(); 185 | }); 186 | return b; 187 | }); 188 | } 189 | 190 | onOpen() { 191 | this.display(); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/settings/modals/MessageDistributionRules.ts: -------------------------------------------------------------------------------- 1 | import { Modal, normalizePath, Setting } from "obsidian"; 2 | import TelegramSyncPlugin from "../../main"; 3 | import { 4 | defaultFileNameTemplate, 5 | defaultNoteNameTemplate, 6 | extractConditionsFromFilterQuery, 7 | createBlankMessageDistributionRule, 8 | MessageDistributionRule, 9 | } from "../messageDistribution"; 10 | import { FileSuggest } from "../suggesters/FileSuggester"; 11 | import { _15sec, displayAndLog } from "../../utils/logUtils"; 12 | 13 | export class MessageDistributionRulesModal extends Modal { 14 | messageDistributionRule: MessageDistributionRule; 15 | messageDistributionRulesDiv: HTMLDivElement; 16 | plugin: TelegramSyncPlugin; 17 | saved = false; 18 | editing = false; 19 | 20 | constructor(plugin: TelegramSyncPlugin, messageDistributionRule?: MessageDistributionRule) { 21 | super(plugin.app); 22 | this.plugin = plugin; 23 | if (messageDistributionRule) { 24 | this.editing = true; 25 | this.messageDistributionRule = messageDistributionRule; 26 | } else this.messageDistributionRule = createBlankMessageDistributionRule(); 27 | } 28 | 29 | async display() { 30 | this.modalEl.style.height = "90vh"; 31 | this.modalEl.style.width = "60vw"; 32 | this.addHeader(); 33 | this.addMessageFilter(); 34 | this.addTemplateFilePath(); 35 | this.addNotePathTemplate(); 36 | this.addFilePathTemplate(); 37 | this.addHeading(); 38 | this.addMessageSortingMode(); 39 | this.addFooterButtons(); 40 | } 41 | 42 | addHeader() { 43 | this.contentEl.empty(); 44 | this.messageDistributionRulesDiv = this.contentEl.createDiv(); 45 | this.titleEl.setText(`${this.editing ? "Editing" : "Adding"} message distribution rule`); 46 | new Setting(this.messageDistributionRulesDiv).descEl.createEl("a", { 47 | text: "🗎 User guide", 48 | href: "https://github.com/soberhacker/obsidian-telegram-sync/blob/main/docs/Template%20Variables%20List.md", 49 | }); 50 | } 51 | 52 | addMessageFilter() { 53 | const setting = new Setting(this.messageDistributionRulesDiv) 54 | .setName("Message filter") 55 | .setDesc( 56 | "Conditions by which you would like to filter messages. Leave the field blank if you want to apply this rule to all messages", 57 | ) 58 | .addTextArea((text) => { 59 | text.setValue(this.messageDistributionRule.messageFilterQuery) 60 | .onChange(async (filterQuery: string) => { 61 | this.messageDistributionRule.messageFilterQuery = filterQuery; 62 | this.messageDistributionRule.messageFilterConditions = 63 | extractConditionsFromFilterQuery(filterQuery); 64 | }) 65 | .setPlaceholder("example: {{topic=Notes}}{{user=soberhacker}}"); 66 | }); 67 | setSettingStyles(setting); 68 | } 69 | 70 | addTemplateFilePath() { 71 | const setting = new Setting(this.messageDistributionRulesDiv) 72 | .setName("Template file path") 73 | .setDesc("Specify path to template file you want to apply to new notes") 74 | .addSearch((cb) => { 75 | new FileSuggest(cb.inputEl, this.plugin); 76 | cb.setPlaceholder("example: folder/zettelkasten.md") 77 | .setValue(this.messageDistributionRule.templateFilePath) 78 | .onChange(async (path) => { 79 | this.messageDistributionRule.templateFilePath = path ? normalizePath(path) : path; 80 | }); 81 | }); 82 | setSettingStyles(setting); 83 | } 84 | 85 | addNotePathTemplate() { 86 | const setting = new Setting(this.messageDistributionRulesDiv) 87 | .setName("Note path template") 88 | .setDesc( 89 | "Specify path template for storage folders and note names. Leave empty if you don't want to create any notes from filtrated messages", 90 | ) 91 | .addTextArea((text) => { 92 | text.setPlaceholder(`example: folder/${defaultNoteNameTemplate}`) 93 | .setValue(this.messageDistributionRule.notePathTemplate) 94 | .onChange(async (value: string) => { 95 | this.messageDistributionRule.notePathTemplate = value; 96 | }); 97 | }); 98 | setSettingStyles(setting); 99 | } 100 | 101 | addFilePathTemplate() { 102 | const setting = new Setting(this.messageDistributionRulesDiv); 103 | setting 104 | .setName("File path template") 105 | .setDesc( 106 | "Specify path template for storage folders and file names. Leave empty if you don't want to save any files from filtrated messages", 107 | ) 108 | .addTextArea((text) => { 109 | text.setPlaceholder(`example: folder/${defaultFileNameTemplate}`) 110 | .setValue(this.messageDistributionRule.filePathTemplate) 111 | .onChange(async (value: string) => { 112 | this.messageDistributionRule.filePathTemplate = value; 113 | }); 114 | }); 115 | setSettingStyles(setting); 116 | } 117 | 118 | addHeading() { 119 | const setting = new Setting(this.messageDistributionRulesDiv); 120 | setting 121 | .setName("Heading") 122 | .setDesc("Specify the heading under which new messages will be inserted") 123 | .addText((text) => { 124 | text.setPlaceholder(`example: ### Log`) 125 | .setValue(this.messageDistributionRule.heading) 126 | .onChange(async (value: string) => { 127 | this.messageDistributionRule.heading = value; 128 | }); 129 | }); 130 | setSettingStyles(setting); 131 | } 132 | 133 | addMessageSortingMode() { 134 | const setting = new Setting(this.messageDistributionRulesDiv); 135 | setting 136 | .setName("Reversed order") 137 | .setDesc( 138 | `Turn on to have new messages appear at the beginning of the note, or, if a heading is specified, above it. Warning: If "Parallel Message Processing" turn on, it may disrupt message order`, 139 | ) 140 | .addToggle((toggle) => { 141 | toggle.setValue(this.messageDistributionRule.reversedOrder); 142 | toggle.onChange(async (value) => { 143 | this.messageDistributionRule.reversedOrder = value; 144 | }); 145 | }); 146 | } 147 | 148 | addFooterButtons() { 149 | this.messageDistributionRulesDiv.createEl("br"); 150 | const footerButtons = new Setting(this.contentEl.createDiv()); 151 | footerButtons.addButton((b) => { 152 | b.setTooltip("Submit") 153 | .setIcon("checkmark") 154 | .onClick(async () => { 155 | const template = this.messageDistributionRule.templateFilePath; 156 | const notePath = this.messageDistributionRule.notePathTemplate; 157 | const filePath = this.messageDistributionRule.filePathTemplate; 158 | if (!template && !notePath && !filePath) { 159 | displayAndLog(this.plugin, "Please, fill at least one field", _15sec); 160 | return; 161 | } 162 | if ( 163 | (template && (template == notePath || template == filePath)) || 164 | (filePath && filePath == notePath) 165 | ) { 166 | displayAndLog( 167 | this.plugin, 168 | `"Template file path", "Note path template" and "File path template" must not be equal to one another`, 169 | _15sec, 170 | ); 171 | return; 172 | } 173 | if (!this.editing) this.plugin.settings.messageDistributionRules.push(this.messageDistributionRule); 174 | await this.plugin.saveSettings(); 175 | this.saved = true; 176 | this.close(); 177 | }); 178 | return b; 179 | }); 180 | footerButtons.addExtraButton((b) => { 181 | b.setIcon("cross") 182 | .setTooltip("Cancel") 183 | .onClick(async () => { 184 | await this.plugin.loadSettings(); 185 | this.saved = false; 186 | this.close(); 187 | }); 188 | return b; 189 | }); 190 | } 191 | onOpen() { 192 | this.display(); 193 | } 194 | } 195 | function setSettingStyles(setting: Setting) { 196 | setting.infoEl.style.width = "55%"; 197 | setting.controlEl.style.width = "45%"; 198 | const el = setting.controlEl.firstElementChild; 199 | if (!el) return; 200 | if (el instanceof HTMLTextAreaElement) { 201 | el.style.height = "4.5em"; 202 | el.style.width = "100%"; 203 | } 204 | if (el instanceof HTMLInputElement) { 205 | el.style.width = "100%"; 206 | } 207 | 208 | if (el instanceof HTMLDivElement && el.className == "search-input-container") { 209 | el.style.width = "100%"; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/settings/modals/PinCode.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting } from "obsidian"; 2 | import TelegramSyncPlugin from "src/main"; 3 | import { _5sec } from "src/utils/logUtils"; 4 | 5 | export class PinCodeModal extends Modal { 6 | pinCodeDiv: HTMLDivElement; 7 | saved = false; 8 | constructor( 9 | public plugin: TelegramSyncPlugin, 10 | public decrypt = false, 11 | ) { 12 | super(plugin.app); 13 | } 14 | 15 | async display() { 16 | this.addHeader(); 17 | this.addPinCode(); 18 | this.addFooterButtons(); 19 | } 20 | 21 | success = async () => { 22 | this.saved = true; 23 | this.close(); 24 | }; 25 | 26 | addHeader() { 27 | this.contentEl.empty(); 28 | this.pinCodeDiv = this.contentEl.createDiv(); 29 | this.titleEl.setText("Telegram Sync: " + (this.decrypt ? "Decrypting" : "Encrypting") + " bot token"); 30 | } 31 | 32 | addPinCode() { 33 | new Setting(this.pinCodeDiv) 34 | .setName("PIN code") 35 | .setDesc("Enter your PIN code. Numbers and letters only.") 36 | .addText((text) => { 37 | text.setPlaceholder("example: 1234").onChange(async (value: string) => { 38 | if (!value) { 39 | text.inputEl.style.borderColor = "red"; 40 | text.inputEl.style.borderWidth = "2px"; 41 | text.inputEl.style.borderStyle = "solid"; 42 | } 43 | this.plugin.pinCode = value; 44 | }); 45 | text.inputEl.addEventListener("keydown", (event: KeyboardEvent) => { 46 | if (!(event.key === "Enter")) return; 47 | this.success.call(this); 48 | }); 49 | }); 50 | } 51 | 52 | addFooterButtons() { 53 | this.pinCodeDiv.createEl("br"); 54 | const footerButtons = new Setting(this.contentEl.createDiv()); 55 | footerButtons.addButton((b) => { 56 | b.setTooltip("Connect").setIcon("checkmark").onClick(this.success); 57 | return b; 58 | }); 59 | footerButtons.addExtraButton((b) => { 60 | b.setIcon("cross") 61 | .setTooltip("Cancel") 62 | .onClick(async () => { 63 | this.saved = false; 64 | this.plugin.pinCode = undefined; 65 | this.close(); 66 | }); 67 | return b; 68 | }); 69 | } 70 | 71 | onOpen() { 72 | this.display(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/settings/modals/ProcessOldMessagesSettings.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent, Modal, Setting } from "obsidian"; 2 | import TelegramSyncPlugin from "src/main"; 3 | import { getChatsForSearch } from "src/telegram/user/sync"; 4 | 5 | export class ProcessOldMessagesSettingsModal extends Modal { 6 | processOldMessagesSettingsDiv: HTMLDivElement; 7 | saved = false; 8 | constructor(public plugin: TelegramSyncPlugin) { 9 | super(plugin.app); 10 | } 11 | 12 | async display() { 13 | this.addHeader(); 14 | await this.addChatsForSearch(); 15 | } 16 | 17 | addHeader() { 18 | this.contentEl.empty(); 19 | this.processOldMessagesSettingsDiv = this.contentEl.createDiv(); 20 | this.titleEl.setText("Processing old messages settings"); 21 | } 22 | 23 | async addChatsForSearch() { 24 | new Setting(this.processOldMessagesSettingsDiv).setName("Chats for message search").setHeading(); 25 | this.plugin.settings.processOldMessagesSettings.chatsForSearch.forEach((chat) => { 26 | const setting = new Setting(this.processOldMessagesSettingsDiv); 27 | setting.setName(`"${chat.name}"`); 28 | setting.addExtraButton((btn) => { 29 | btn.setIcon("trash-2") 30 | .setTooltip("Delete") 31 | .onClick(async () => { 32 | this.plugin.settings.processOldMessagesSettings.chatsForSearch.remove(chat); 33 | await this.plugin.saveSettings(); 34 | this.display(); 35 | }); 36 | }); 37 | }); 38 | new Setting(this.processOldMessagesSettingsDiv) 39 | .setDesc( 40 | "Choose chats with your connected bot in which to search for old messages. Only chats with activity in the last 30 days will be available. If no chat is chosen, old message processing will not occur", 41 | ) 42 | .addButton(async (btn: ButtonComponent) => { 43 | btn.setButtonText("Add chats"); 44 | btn.setClass("mod-cta"); 45 | btn.onClick(async () => { 46 | this.plugin.settings.processOldMessagesSettings.chatsForSearch = await getChatsForSearch( 47 | this.plugin, 48 | 30, 49 | ); 50 | await this.plugin.saveSettings(); 51 | this.display(); 52 | }); 53 | }); 54 | } 55 | 56 | onOpen() { 57 | this.display(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/settings/modals/UserLogin.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting } from "obsidian"; 2 | import TelegramSyncPlugin from "src/main"; 3 | import * as User from "src/telegram/user/user"; 4 | 5 | export class UserLogInModal extends Modal { 6 | userLoginDiv: HTMLDivElement; 7 | qrCodeContainer: HTMLDivElement; 8 | password = ""; 9 | constructor(public plugin: TelegramSyncPlugin) { 10 | super(plugin.app); 11 | } 12 | 13 | async display() { 14 | this.addHeader(); 15 | this.addPassword(); 16 | this.addScanner(); 17 | await this.addQrCode(); 18 | this.addCheck(); 19 | this.addFooterButtons(); 20 | } 21 | 22 | addHeader() { 23 | this.contentEl.empty(); 24 | this.userLoginDiv = this.contentEl.createDiv(); 25 | this.titleEl.setText("User authorization"); 26 | } 27 | 28 | addPassword() { 29 | new Setting(this.userLoginDiv) 30 | .setName("1. Enter password (optionally)") 31 | .setDesc( 32 | "Enter your password before scanning QR code only if you use two-step authorization. Password will not be stored", 33 | ) 34 | .addText((text) => { 35 | text.setPlaceholder("*************") 36 | .setValue("") 37 | .onChange(async (value: string) => { 38 | this.password = value; 39 | }); 40 | }); 41 | } 42 | 43 | addScanner() { 44 | new Setting(this.userLoginDiv) 45 | .setName("2. Prepare QR code scanner") 46 | .setDesc("Open Telegram on your phone. Go to Settings > Devices > Link Desktop Device"); 47 | } 48 | 49 | async addQrCode() { 50 | new Setting(this.userLoginDiv) 51 | .setName("3. Generate & scan QR code") 52 | .setDesc(`Generate QR code and point your phone at it to confirm login`) 53 | .addButton((b) => { 54 | b.setButtonText("Generate QR code"); 55 | b.onClick(async () => { 56 | await this.showQrCodeGeneratingState("🔵 QR code generating...\n", "#007BFF"); 57 | const error = await User.connect( 58 | this.plugin, 59 | "user", 60 | undefined, 61 | this.qrCodeContainer, 62 | this.password, 63 | ); 64 | if (error) await this.showQrCodeGeneratingState(`🔴 ${error}\n`, "#FF0000"); 65 | else await this.showQrCodeGeneratingState("🟢 Successfully logged in!\n", "#008000"); 66 | }); 67 | }); 68 | this.qrCodeContainer = this.userLoginDiv.createDiv({ 69 | cls: "qr-code-container", 70 | }); 71 | } 72 | 73 | addCheck() { 74 | new Setting(this.userLoginDiv) 75 | .setName("4. Check active sessions") 76 | .setDesc( 77 | `If the login is successful, you will find the 'Obsidian Telegram Sync' session in the list of active sessions. If you find it in the list of inactive sessions, then you have probably entered the wrong password`, 78 | ); 79 | } 80 | addFooterButtons() { 81 | this.userLoginDiv.createEl("br"); 82 | const footerButtons = new Setting(this.contentEl.createDiv()); 83 | footerButtons.addButton((b) => { 84 | b.setIcon("checkmark"); 85 | b.setButtonText("ok"); 86 | b.onClick(async () => this.close()); 87 | }); 88 | } 89 | 90 | async onOpen() { 91 | await this.display(); 92 | } 93 | 94 | cleanQrContainer() { 95 | while (this.qrCodeContainer.firstChild) { 96 | this.qrCodeContainer.removeChild(this.qrCodeContainer.firstChild); 97 | } 98 | } 99 | 100 | async showQrCodeGeneratingState(text: string, color?: string) { 101 | this.cleanQrContainer(); 102 | const message = this.qrCodeContainer.createEl("pre", { text }); 103 | if (color) message.style.color = color; 104 | message.style.fontWeight = "bold"; 105 | message.style.whiteSpace = "pre-wrap"; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/settings/suggesters/FileSuggester.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { TAbstractFile, TFile } from "obsidian"; 4 | import { TextInputSuggest } from "./suggest"; 5 | import TelegramSyncPlugin from "src/main"; 6 | 7 | export class FileSuggest extends TextInputSuggest { 8 | constructor( 9 | public inputEl: HTMLInputElement, 10 | public plugin: TelegramSyncPlugin, 11 | ) { 12 | super(inputEl); 13 | } 14 | 15 | getSuggestions(input_str: string): TFile[] { 16 | const all_files = this.plugin.app.vault.getAllLoadedFiles(); 17 | 18 | if (!all_files) { 19 | return []; 20 | } 21 | 22 | const files: TFile[] = []; 23 | const lower_input_str = input_str.toLowerCase(); 24 | 25 | all_files.forEach((file: TAbstractFile) => { 26 | if (file instanceof TFile && file.extension === "md" && file.path.toLowerCase().contains(lower_input_str)) { 27 | files.push(file); 28 | } 29 | }); 30 | 31 | return files; 32 | } 33 | 34 | renderSuggestion(file: TFile, el: HTMLElement): void { 35 | el.setText(file.path); 36 | } 37 | 38 | selectSuggestion(file: TFile): void { 39 | this.inputEl.value = file.path; 40 | this.inputEl.trigger("input"); 41 | this.close(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/settings/suggesters/suggest.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { ISuggestOwner, Scope } from "obsidian"; 4 | import { createPopper, Instance as PopperInstance } from "@popperjs/core"; 5 | 6 | const wrapAround = (value: number, size: number): number => { 7 | return ((value % size) + size) % size; 8 | }; 9 | 10 | class Suggest { 11 | private owner: ISuggestOwner; 12 | private values: T[]; 13 | private suggestions: HTMLDivElement[]; 14 | private selectedItem: number; 15 | private containerEl: HTMLElement; 16 | 17 | constructor(owner: ISuggestOwner, containerEl: HTMLElement, scope: Scope) { 18 | this.owner = owner; 19 | this.containerEl = containerEl; 20 | 21 | containerEl.on("click", ".suggestion-item", this.onSuggestionClick.bind(this)); 22 | containerEl.on("mousemove", ".suggestion-item", this.onSuggestionMouseover.bind(this)); 23 | 24 | scope.register([], "ArrowUp", (event) => { 25 | if (!event.isComposing) { 26 | this.setSelectedItem(this.selectedItem - 1, true); 27 | return false; 28 | } 29 | }); 30 | 31 | scope.register([], "ArrowDown", (event) => { 32 | if (!event.isComposing) { 33 | this.setSelectedItem(this.selectedItem + 1, true); 34 | return false; 35 | } 36 | }); 37 | 38 | scope.register([], "Enter", (event) => { 39 | if (!event.isComposing) { 40 | this.useSelectedItem(event); 41 | return false; 42 | } 43 | }); 44 | } 45 | 46 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 47 | event.preventDefault(); 48 | 49 | const item = this.suggestions.indexOf(el); 50 | this.setSelectedItem(item, false); 51 | this.useSelectedItem(event); 52 | } 53 | 54 | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { 55 | const item = this.suggestions.indexOf(el); 56 | this.setSelectedItem(item, false); 57 | } 58 | 59 | setSuggestions(values: T[]) { 60 | this.containerEl.empty(); 61 | const suggestionEls: HTMLDivElement[] = []; 62 | 63 | values.forEach((value) => { 64 | const suggestionEl = this.containerEl.createDiv("suggestion-item"); 65 | this.owner.renderSuggestion(value, suggestionEl); 66 | suggestionEls.push(suggestionEl); 67 | }); 68 | 69 | this.values = values; 70 | this.suggestions = suggestionEls; 71 | this.setSelectedItem(0, false); 72 | } 73 | 74 | useSelectedItem(event: MouseEvent | KeyboardEvent) { 75 | const currentValue = this.values[this.selectedItem]; 76 | if (currentValue) { 77 | this.owner.selectSuggestion(currentValue, event); 78 | } 79 | } 80 | 81 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { 82 | const normalizedIndex = wrapAround(selectedIndex, this.suggestions.length); 83 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 84 | const selectedSuggestion = this.suggestions[normalizedIndex]; 85 | 86 | prevSelectedSuggestion?.removeClass("is-selected"); 87 | selectedSuggestion?.addClass("is-selected"); 88 | 89 | this.selectedItem = normalizedIndex; 90 | 91 | if (scrollIntoView) { 92 | selectedSuggestion.scrollIntoView(false); 93 | } 94 | } 95 | } 96 | 97 | export abstract class TextInputSuggest implements ISuggestOwner { 98 | protected inputEl: HTMLInputElement | HTMLTextAreaElement; 99 | 100 | private popper: PopperInstance; 101 | private scope: Scope; 102 | private suggestEl: HTMLElement; 103 | private suggest: Suggest; 104 | 105 | constructor(inputEl: HTMLInputElement | HTMLTextAreaElement) { 106 | this.inputEl = inputEl; 107 | this.scope = new Scope(); 108 | 109 | this.suggestEl = createDiv("suggestion-container"); 110 | const suggestion = this.suggestEl.createDiv("suggestion"); 111 | this.suggest = new Suggest(this, suggestion, this.scope); 112 | 113 | this.scope.register([], "Escape", this.close.bind(this)); 114 | 115 | this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); 116 | this.inputEl.addEventListener("focus", this.onInputChanged.bind(this)); 117 | this.inputEl.addEventListener("blur", this.close.bind(this)); 118 | this.suggestEl.on("mousedown", ".suggestion-container", (event: MouseEvent) => { 119 | event.preventDefault(); 120 | }); 121 | } 122 | 123 | onInputChanged(): void { 124 | const inputStr = this.inputEl.value; 125 | const suggestions = this.getSuggestions(inputStr); 126 | 127 | if (!suggestions) { 128 | this.close(); 129 | return; 130 | } 131 | 132 | if (suggestions.length > 0) { 133 | this.suggest.setSuggestions(suggestions); 134 | this.open(app.workspace.containerEl, this.inputEl); 135 | } else { 136 | this.close(); 137 | } 138 | } 139 | 140 | open(container: HTMLElement, inputEl: HTMLElement): void { 141 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 142 | app.keymap.pushScope(this.scope); 143 | 144 | container.appendChild(this.suggestEl); 145 | this.popper = createPopper(inputEl, this.suggestEl, { 146 | placement: "bottom-start", 147 | modifiers: [ 148 | { 149 | name: "sameWidth", 150 | enabled: true, 151 | fn: ({ state, instance }) => { 152 | // Note: positioning needs to be calculated twice - 153 | // first pass - positioning it according to the width of the popper 154 | // second pass - position it with the width bound to the reference element 155 | // we need to early exit to avoid an infinite loop 156 | const targetWidth = `${state.rects.reference.width}px`; 157 | if (state.styles.popper.width === targetWidth) { 158 | return; 159 | } 160 | state.styles.popper.width = targetWidth; 161 | instance.update(); 162 | }, 163 | phase: "beforeWrite", 164 | requires: ["computeStyles"], 165 | }, 166 | ], 167 | }); 168 | } 169 | 170 | close(): void { 171 | app.keymap.popScope(this.scope); 172 | 173 | this.suggest.setSuggestions([]); 174 | if (this.popper) this.popper.destroy(); 175 | this.suggestEl.detach(); 176 | } 177 | 178 | abstract getSuggestions(inputStr: string): T[]; 179 | abstract renderSuggestion(item: T, el: HTMLElement): void; 180 | abstract selectSuggestion(item: T): void; 181 | } 182 | -------------------------------------------------------------------------------- /src/telegram/bot/bot.ts: -------------------------------------------------------------------------------- 1 | import TelegramBot from "node-telegram-bot-api"; 2 | import TelegramSyncPlugin from "src/main"; 3 | import { _1sec, displayAndLog } from "src/utils/logUtils"; 4 | import { handleMessage } from "./message/handlers"; 5 | import { reconnect } from "../user/user"; 6 | import { enqueue, enqueueByCondition } from "src/utils/queues"; 7 | 8 | // Initialize the Telegram bot and set up message handling 9 | export async function connect(plugin: TelegramSyncPlugin) { 10 | if (plugin.checkingUserConnection) return; 11 | plugin.checkingBotConnection = true; 12 | try { 13 | await disconnect(plugin); 14 | 15 | if (!plugin.settings.botToken) { 16 | displayAndLog(plugin, "Telegram bot token is empty.\n\nSyncing is disabled."); 17 | plugin.checkingBotConnection = false; 18 | return; 19 | } 20 | // Create a new bot instance and start polling 21 | plugin.bot = new TelegramBot(await enqueue(plugin, plugin.getBotToken)); 22 | const bot = plugin.bot; 23 | // Set connected flag to false and log errors when a polling error occurs 24 | bot.on("polling_error", async (error: unknown) => { 25 | handlePollingError(plugin, error); 26 | }); 27 | 28 | bot.on("channel_post", async (msg) => { 29 | await enqueueByCondition(!plugin.settings.parallelMessageProcessing, handleMessage, plugin, msg, true); 30 | }); 31 | 32 | bot.on("edited_message", async (msg) => { 33 | await enqueueByCondition(!plugin.settings.parallelMessageProcessing, handleMessage, plugin, msg); 34 | }); 35 | 36 | bot.on("message", async (msg) => { 37 | await enqueueByCondition(!plugin.settings.parallelMessageProcessing, handleMessage, plugin, msg); 38 | }); 39 | 40 | // Check if the bot is connected and set the connected flag accordingly 41 | try { 42 | plugin.botUser = await bot.getMe(); 43 | plugin.lastPollingErrors = []; 44 | } finally { 45 | await bot.startPolling(); 46 | } 47 | plugin.setBotStatus("connected"); 48 | plugin.time4processOldMessages = true; 49 | } catch (error) { 50 | if (!plugin.bot || !plugin.bot.isPolling()) { 51 | plugin.setBotStatus("disconnected", error); 52 | } 53 | } finally { 54 | plugin.checkingBotConnection = false; 55 | } 56 | } 57 | 58 | // Stop the bot polling 59 | export async function disconnect(plugin: TelegramSyncPlugin) { 60 | try { 61 | plugin.bot && (await plugin.bot.stopPolling()); 62 | } finally { 63 | plugin.bot = undefined; 64 | plugin.botUser = undefined; 65 | plugin.setBotStatus("disconnected"); 66 | } 67 | } 68 | 69 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 70 | async function handlePollingError(plugin: TelegramSyncPlugin, error: any) { 71 | let pollingError = "unknown"; 72 | 73 | try { 74 | const errorCode = error.response.body.error_code; 75 | 76 | if (errorCode === 409) { 77 | pollingError = "twoBotInstances"; 78 | } 79 | 80 | if (errorCode === 401) { 81 | pollingError = "unAuthorized"; 82 | } 83 | } catch { 84 | try { 85 | pollingError = error.code === "EFATAL" ? "fatalError" : pollingError; 86 | } catch { 87 | pollingError = "unknown"; 88 | } 89 | } 90 | 91 | if (plugin.lastPollingErrors.length == 0 || !plugin.lastPollingErrors.includes(pollingError)) { 92 | plugin.lastPollingErrors.push(pollingError); 93 | if (!(pollingError == "twoBotInstances")) { 94 | plugin.setBotStatus("disconnected", error); 95 | } 96 | } 97 | 98 | if (!(pollingError == "twoBotInstances")) checkConnectionAfterError(plugin); 99 | } 100 | 101 | async function checkConnectionAfterError(plugin: TelegramSyncPlugin, intervalInSeconds = 15) { 102 | if (plugin.checkingBotConnection || !plugin.bot || !plugin.bot.isPolling()) return; 103 | if (!plugin.checkingBotConnection && plugin.isBotConnected()) plugin.lastPollingErrors = []; 104 | try { 105 | plugin.checkingBotConnection = true; 106 | await new Promise((resolve) => setTimeout(resolve, intervalInSeconds * _1sec)); 107 | plugin.botUser = await plugin.bot.getMe(); 108 | plugin.setBotStatus("connected"); 109 | plugin.lastPollingErrors = []; 110 | plugin.checkingBotConnection = false; 111 | reconnect(plugin); 112 | plugin.time4processOldMessages = true; 113 | } catch { 114 | plugin.checkingBotConnection = false; 115 | } 116 | } 117 | 118 | export async function setReaction(plugin: TelegramSyncPlugin, msg: TelegramBot.Message, emoji: string) { 119 | await plugin.bot?.setMessageReaction(msg.chat.id, msg.message_id, { reaction: [{ emoji: emoji, type: "emoji" }] }); 120 | } 121 | -------------------------------------------------------------------------------- /src/telegram/bot/message/convertToMarkdown.ts: -------------------------------------------------------------------------------- 1 | import TelegramBot from "node-telegram-bot-api"; 2 | import { getInlineUrls } from "./getters"; 3 | 4 | export async function convertMessageTextToMarkdown(msg: TelegramBot.Message): Promise { 5 | let text = msg.text || msg.caption || ""; 6 | const entities = msg.entities || msg.caption_entities || []; 7 | const copiedEntities: TelegramBot.MessageEntity[] = structuredClone(entities); 8 | copiedEntities.forEach((entity, index, updatedEntities) => { 9 | const entityStart = entity.offset; 10 | let entityEnd = entityStart + entity.length; 11 | let entityText = text.slice(entityStart, entityEnd); 12 | // skip trailing new lines 13 | if (entity.type != "pre") entityEnd = entityEnd - entityText.length + entityText.trimEnd().length; 14 | 15 | const beforeEntity = text.slice(0, entityStart); 16 | entityText = text.slice(entityStart, entityEnd); 17 | const afterEntity = text.slice(entityEnd); 18 | 19 | switch (entity.type) { 20 | case "bold": 21 | entityText = `**${entityText}**`; 22 | updateEntitiesOffset(updatedEntities, entity, index, 2, 2); 23 | break; 24 | case "italic": 25 | entityText = `*${entityText}*`; 26 | updateEntitiesOffset(updatedEntities, entity, index, 1, 1); 27 | break; 28 | case "underline": 29 | entityText = `${entityText}`; 30 | updateEntitiesOffset(updatedEntities, entity, index, 3, 4); 31 | break; 32 | case "strikethrough": 33 | entityText = `~~${entityText}~~`; 34 | updateEntitiesOffset(updatedEntities, entity, index, 2, 2); 35 | break; 36 | case "code": 37 | entityText = "`" + entityText + "`"; 38 | updateEntitiesOffset(updatedEntities, entity, index, 1, 1); 39 | break; 40 | case "pre": 41 | entityText = "```\n" + entityText + "\n```"; 42 | updateEntitiesOffset(updatedEntities, entity, index, 4, 4); 43 | break; 44 | case "text_link": 45 | if (entity.url) { 46 | entityText = `[${entityText}](${entity.url})`; 47 | updateEntitiesOffset(updatedEntities, entity, index, 1, entity.url.length + 3); 48 | } 49 | break; 50 | default: 51 | break; 52 | } 53 | text = beforeEntity + entityText + afterEntity; 54 | }); 55 | const inlineUrls = getInlineUrls(msg); 56 | return text + (inlineUrls ? `\n\n${inlineUrls}` : ""); 57 | } 58 | 59 | function updateEntitiesOffset( 60 | currentEntities: TelegramBot.MessageEntity[], 61 | currentEntity: TelegramBot.MessageEntity, 62 | currentIndex: number, 63 | beforeOffset: number, 64 | afterOffset: number, 65 | ) { 66 | currentEntities.forEach((entity, index) => { 67 | if (index <= currentIndex) return; 68 | if (entity.offset >= currentEntity.offset) entity.offset += beforeOffset; 69 | if (entity.offset > currentEntity.offset + currentEntity.length) entity.offset += afterOffset; 70 | }); 71 | } 72 | 73 | export function escapeRegExp(str: string) { 74 | return str.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); 75 | } 76 | -------------------------------------------------------------------------------- /src/telegram/bot/message/donation.ts: -------------------------------------------------------------------------------- 1 | export const nowPaymentsLink = "https://nowpayments.io/donation?api_key=JMM7NE1-M4X4JY6-N8EK1GJ-H8XQXFK"; 2 | export const paypalLink = "https://www.paypal.com/donate/?hosted_button_id=VYSCUZX8MYGCU"; 3 | export const buyMeACoffeeLink = "https://www.buymeacoffee.com/soberhacker"; 4 | export const kofiLink = "https://ko-fi.com/soberhacker"; 5 | 6 | export const donationInlineKeyboard = [ 7 | [ 8 | { text: "🚀 Cryptocurrency", url: nowPaymentsLink }, 9 | { text: "☕ Buy me a coffee", url: buyMeACoffeeLink }, 10 | ], 11 | [ 12 | { text: "💰 Ko-fi Donation", url: kofiLink }, 13 | { text: "💳 PayPal Donation", url: paypalLink }, 14 | ], 15 | ]; 16 | -------------------------------------------------------------------------------- /src/telegram/bot/message/filterEvaluations.ts: -------------------------------------------------------------------------------- 1 | import TelegramBot from "node-telegram-bot-api"; 2 | import { MessageDistributionRule, MessageFilterCondition, ConditionType } from "src/settings/messageDistribution"; 3 | import { getForwardFromName, getTopic } from "./getters"; 4 | import TelegramSyncPlugin from "src/main"; 5 | import * as Client from "src/telegram/user/client"; 6 | 7 | function isUserFiltered(msg: TelegramBot.Message, userNameOrId: string): boolean { 8 | if (!msg?.from || !userNameOrId) return false; 9 | 10 | const user = msg.from; 11 | const fullName = `${user.first_name} ${user.last_name || ""}`.trim(); 12 | const userId = user.id < 0 ? user.id.toString().slice(4) : user.id.toString(); 13 | 14 | return [user.username, userId, fullName].includes(userNameOrId); 15 | } 16 | 17 | function isChatFiltered(msg: TelegramBot.Message, chatNameOrId: string): boolean { 18 | if (!msg?.chat || !chatNameOrId) return false; 19 | 20 | const chat = msg.chat; 21 | const chatId = chat.id < 0 ? chat.id.toString().slice(4) : chat.id.toString(); 22 | 23 | let chatName = ""; 24 | if (chat.type == "private") { 25 | chatName = `${chat.first_name} ${chat.last_name || ""}`.trim(); 26 | } else { 27 | chatName = chat.title || chatId; 28 | } 29 | 30 | return [chatId, chatName].includes(chatNameOrId); 31 | } 32 | 33 | function isForwardFromFiltered(msg: TelegramBot.Message, forwardFromName: string): boolean { 34 | return forwardFromName == getForwardFromName(msg); 35 | } 36 | 37 | export async function isTopicFiltered( 38 | plugin: TelegramSyncPlugin, 39 | msg: TelegramBot.Message, 40 | topicName: string, 41 | ): Promise { 42 | const topic = await getTopic(plugin, msg, false); 43 | if (!topic) return false; 44 | return topicName == topic.name; 45 | } 46 | 47 | export async function isContentFiltered(msg: TelegramBot.Message, substring: string): Promise { 48 | return (msg.text || msg.caption || "").contains(substring); 49 | } 50 | 51 | export async function isVoiceTranscriptFiltered( 52 | plugin: TelegramSyncPlugin, 53 | msg: TelegramBot.Message, 54 | substring: string, 55 | ): Promise { 56 | let voiceTranscript = ""; 57 | if (plugin.bot) voiceTranscript = await Client.transcribeAudio(plugin.bot, msg, await plugin.getBotUser()); 58 | return voiceTranscript.contains(substring); 59 | } 60 | 61 | export async function isMessageFiltered( 62 | plugin: TelegramSyncPlugin, 63 | msg: TelegramBot.Message, 64 | condition: MessageFilterCondition, 65 | ): Promise { 66 | switch (condition.conditionType) { 67 | case ConditionType.ALL: 68 | return true; 69 | case ConditionType.USER: 70 | return isUserFiltered(msg, condition.value); 71 | case ConditionType.CHAT: 72 | return isChatFiltered(msg, condition.value); 73 | case ConditionType.FORWARD_FROM: 74 | return isForwardFromFiltered(msg, condition.value); 75 | case ConditionType.TOPIC: 76 | return await isTopicFiltered(plugin, msg, condition.value); 77 | case ConditionType.CONTENT: 78 | return await isContentFiltered(msg, condition.value); 79 | case ConditionType.VOICE_TRANSCRIPT: 80 | return await isVoiceTranscriptFiltered(plugin, msg, condition.value); 81 | default: 82 | return false; 83 | } 84 | } 85 | 86 | export async function doesMessageMatchRule( 87 | plugin: TelegramSyncPlugin, 88 | msg: TelegramBot.Message, 89 | rule: MessageDistributionRule, 90 | ): Promise { 91 | for (const condition of rule.messageFilterConditions) { 92 | const isFiltered = await isMessageFiltered(plugin, msg, condition); 93 | if (!isFiltered) return false; 94 | } 95 | return true; 96 | } 97 | 98 | export async function getMessageDistributionRule( 99 | plugin: TelegramSyncPlugin, 100 | msg: TelegramBot.Message, 101 | ): Promise { 102 | for (const rule of plugin.settings.messageDistributionRules) { 103 | if (await doesMessageMatchRule(plugin, msg, rule)) return rule; 104 | } 105 | return undefined; 106 | } 107 | -------------------------------------------------------------------------------- /src/telegram/bot/message/getters.ts: -------------------------------------------------------------------------------- 1 | import TelegramBot from "node-telegram-bot-api"; 2 | import LinkifyIt from "linkify-it"; 3 | import TelegramSyncPlugin from "src/main"; 4 | import { Topic } from "src/settings/Settings"; 5 | 6 | const fileTypes = ["photo", "video", "voice", "document", "audio", "video_note"]; 7 | 8 | export function getForwardFromName(msg: TelegramBot.Message): string { 9 | let forwardFromName = ""; 10 | 11 | if (msg.forward_from || msg.forward_from_chat || msg.forward_sender_name || msg.from) { 12 | if (msg.forward_from) { 13 | forwardFromName = 14 | msg.forward_from.first_name + (msg.forward_from.last_name ? " " + msg.forward_from.last_name : ""); 15 | } else if (msg.forward_from_chat) { 16 | forwardFromName = 17 | msg.forward_from_chat.title + (msg.forward_signature ? `(${msg.forward_signature})` : "") || 18 | msg.forward_from_chat.username || 19 | ""; 20 | } else if (msg.forward_sender_name) { 21 | forwardFromName = msg.forward_sender_name; 22 | } else if (msg.from) { 23 | forwardFromName = msg.from.first_name + (msg.from.last_name ? " " + msg.from.last_name : ""); 24 | } 25 | } 26 | 27 | return forwardFromName; 28 | } 29 | 30 | export function getForwardFromLink(msg: TelegramBot.Message): string { 31 | let forwardFromLink = ""; 32 | // TODO if msg.from then created by not forwarded from 33 | if (msg.forward_from || msg.forward_from_chat || msg.forward_sender_name || msg.from) { 34 | const forwardFromName = getForwardFromName(msg); 35 | let username = ""; 36 | 37 | if (msg.forward_from) { 38 | const forward_from_id = 39 | msg.forward_from.id < 0 ? msg.forward_from.id.toString().slice(4) : msg.forward_from.id.toString(); 40 | username = msg.forward_from.username || "no_username_" + forward_from_id; 41 | } else if (msg.forward_from_chat) { 42 | const forward_from_chat_id = 43 | msg.forward_from_chat.id < 0 44 | ? msg.forward_from_chat.id.toString().slice(4) 45 | : msg.forward_from_chat.id.toString(); 46 | username = 47 | (msg.forward_from_chat.username || "c/" + forward_from_chat_id) + 48 | "/" + 49 | (msg.forward_from_message_id || "999999999"); 50 | } else if (msg.forward_sender_name) { 51 | username = "hidden_account_" + msg.forward_date; 52 | } else if (msg.from) { 53 | const from_id = msg.from.id < 0 ? msg.from.id.toString().slice(4) : msg.from.id.toString(); 54 | username = msg.from.username || "no_username_" + from_id; 55 | } 56 | forwardFromLink = `[${forwardFromName}](https://t.me/${username})`; 57 | } 58 | 59 | return forwardFromLink; 60 | } 61 | 62 | export function getUserLink(msg: TelegramBot.Message): string { 63 | if (!msg.from) return ""; 64 | const from_id = msg.from.id < 0 ? msg.from.id.toString().slice(4) : msg.from.id.toString(); 65 | const username = msg.from.username || "no_username_" + from_id; 66 | const fullName = `${msg.from.first_name} ${msg.from.last_name || ""}`.trim(); 67 | return `[${fullName}](https://t.me/${username})`; 68 | } 69 | 70 | export function getChatName(msg: TelegramBot.Message, botUser?: TelegramBot.User): string { 71 | let chatName = ""; 72 | if (botUser?.username && msg.chat.id == msg.from?.id) { 73 | chatName = `${botUser.first_name} ${botUser.last_name || ""}`.trim() || botUser.username; 74 | } else if (msg.chat.type == "private") { 75 | chatName = `${msg.chat.first_name} ${msg.chat.last_name || ""}`.trim(); 76 | } else { 77 | chatName = msg.chat.title || msg.chat.type + msg.chat.id; 78 | } 79 | return chatName; 80 | } 81 | 82 | export function getChatId(msg: TelegramBot.Message, botUser?: TelegramBot.User): string { 83 | let chatId = ""; 84 | if (botUser?.username && msg.chat.id == msg.from?.id) { 85 | chatId = botUser.id.toString(); 86 | } else { 87 | chatId = msg.chat.id.toString(); 88 | } 89 | return chatId; 90 | } 91 | 92 | export function getChatLink(msg: TelegramBot.Message, botUser?: TelegramBot.User): string { 93 | let userName = ""; 94 | if (botUser?.username && msg.chat.id == msg.from?.id) { 95 | userName = botUser?.username; 96 | } else if (msg.chat.type == "private") { 97 | const chat_id = msg.chat.id < 0 ? msg.chat.id.toString().slice(4) : msg.chat.id.toString(); 98 | userName = msg.chat.username || "no_username_" + chat_id; 99 | } else { 100 | const threadId = msg.chat.is_forum 101 | ? msg.message_thread_id || msg.reply_to_message?.message_thread_id || "1" 102 | : ""; 103 | const chatId = msg.chat.id < 0 ? msg.chat.id.toString().slice(4) : msg.chat.id.toString(); 104 | userName = msg.chat.username || `c/${chatId}/${threadId}/${msg.message_id}`; 105 | userName = userName.replace(/\/\//g, "/"); // because threadId can be empty 106 | } 107 | const chatName = getChatName(msg, botUser); 108 | const chatLink = `[${chatName}](https://t.me/${userName})`; 109 | return chatLink; 110 | } 111 | 112 | export function getUrl(msg: TelegramBot.Message, num = 1, lookInCaptions = true): string { 113 | const text = (msg.text || "") + (lookInCaptions && msg.caption ? msg.caption : ""); 114 | if (!text) return ""; 115 | 116 | const linkify = LinkifyIt(); 117 | const matches = linkify.match(text); 118 | return matches ? matches[num - 1].url : ""; 119 | } 120 | 121 | export function getHashtag(msg: TelegramBot.Message, num = 1, lookInCaptions = true): string { 122 | const text = (msg.text || "") + (lookInCaptions && msg.caption ? msg.caption : ""); 123 | if (!text) return ""; 124 | 125 | const hashtags = text.match(/#[\p{L}\p{N}_]+/gu) || []; 126 | return hashtags[num - 1]?.replace("#", "") || ""; 127 | } 128 | 129 | export function getInlineUrls(msg: TelegramBot.Message): string { 130 | let urls = ""; 131 | if (!msg.reply_markup?.inline_keyboard || msg.reply_markup.inline_keyboard.length == 0) return ""; 132 | msg.reply_markup.inline_keyboard.forEach((buttonsGroup) => { 133 | buttonsGroup.forEach((button) => { 134 | if (button.url) { 135 | urls += `[${button.text}](${button.url})\n`; 136 | } 137 | }); 138 | }); 139 | return urls.trimEnd(); 140 | } 141 | 142 | export function getTopicId(msg: TelegramBot.Message): number | undefined { 143 | return (msg.chat.is_forum && (msg.message_thread_id || msg.reply_to_message?.message_thread_id || 1)) || undefined; 144 | } 145 | 146 | export async function getTopic( 147 | plugin: TelegramSyncPlugin, 148 | msg: TelegramBot.Message, 149 | throwError = true, 150 | ): Promise { 151 | if (!msg.chat.is_forum) return undefined; 152 | 153 | const reply = msg.reply_to_message; 154 | const topicId = getTopicId(msg) || 1; 155 | let topic = plugin.settings.topicNames.find((tn) => tn.chatId == msg.chat.id && tn.topicId == topicId); 156 | if (!topic && reply?.forum_topic_created?.name) { 157 | topic = { 158 | name: reply?.forum_topic_created?.name, 159 | chatId: msg.chat.id, 160 | topicId: topicId, 161 | }; 162 | plugin.settings.topicNames.push(topic); 163 | await plugin.saveSettings(); 164 | } 165 | if (!topic && throwError) { 166 | throw new Error( 167 | "Telegram bot has a limitation to get topic names. if the topic name displays incorrect, set the name manually using bot command `/topicName NAME`", 168 | ); 169 | } 170 | return topic; 171 | } 172 | 173 | export async function getTopicLink(plugin: TelegramSyncPlugin, msg: TelegramBot.Message): Promise { 174 | if (!msg.chat.is_forum) return ""; 175 | const topic = await getTopic(plugin, msg); 176 | if (!topic) return ""; 177 | const chatId = topic.chatId < 0 ? topic.chatId.toString().slice(4) : topic.chatId.toString(); 178 | const path = (msg.chat.username || `c/${chatId}`) + `/${topic.topicId}`; 179 | return `[${topic.name}](https://t.me/${path})`; 180 | } 181 | 182 | export function getReplyMessageId(msg: TelegramBot.Message): string { 183 | const reply = msg.reply_to_message; 184 | if (reply && reply.message_thread_id != reply.message_id) { 185 | return reply.message_id.toString(); 186 | } 187 | return ""; 188 | } 189 | 190 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 191 | export function getFileObject(msg: TelegramBot.Message): { fileType: string; fileObject?: any } { 192 | for (const fileType of fileTypes) { 193 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 194 | if ((msg as any)[fileType]) { 195 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 196 | return { fileType: fileType, fileObject: (msg as any)[fileType] }; 197 | } 198 | } 199 | return { fileType: "undefined", fileObject: undefined }; 200 | } 201 | -------------------------------------------------------------------------------- /src/telegram/bot/message/handlers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-mixed-spaces-and-tabs */ 2 | import TelegramSyncPlugin from "../../../main"; 3 | import TelegramBot from "node-telegram-bot-api"; 4 | import { appendContentToNote, createFolderIfNotExist, defaultDelimiter, getUniqueFilePath } from "src/utils/fsUtils"; 5 | import * as release from "../../../../release-notes.mjs"; 6 | import { donationInlineKeyboard } from "./donation"; 7 | import { SendMessageOptions } from "node-telegram-bot-api"; 8 | import path from "path"; 9 | import * as Client from "../../user/client"; 10 | import { extension } from "mime-types"; 11 | import { 12 | applyFilesPathTemplate, 13 | applyNoteContentTemplate, 14 | applyNotePathTemplate, 15 | finalizeMessageProcessing, 16 | } from "./processors"; 17 | import { ProgressBarType, _3MB, createProgressBar, deleteProgressBar, updateProgressBar } from "../progressBar"; 18 | import { getFileObject } from "./getters"; 19 | import { TFile } from "obsidian"; 20 | import { enqueue } from "src/utils/queues"; 21 | import { _15sec, _1sec, displayAndLog, displayAndLogError } from "src/utils/logUtils"; 22 | import { getMessageDistributionRule } from "./filterEvaluations"; 23 | import { MessageDistributionRule, getMessageDistributionRuleInfo } from "src/settings/messageDistribution"; 24 | import { getOffsetDate, unixTime2Date } from "src/utils/dateUtils"; 25 | import { addOriginalUserMsg, canUpdateProcessingDate } from "src/telegram/user/sync"; 26 | 27 | interface MediaGroup { 28 | id: string; 29 | notePath: string; 30 | initialMsg: TelegramBot.Message; 31 | mediaMessages: TelegramBot.Message[]; 32 | error?: Error; 33 | filesPaths: string[]; 34 | } 35 | 36 | const mediaGroups: MediaGroup[] = []; 37 | 38 | let handleMediaGroupIntervalId: NodeJS.Timer | undefined; 39 | 40 | export function clearHandleMediaGroupInterval() { 41 | clearInterval(handleMediaGroupIntervalId); 42 | handleMediaGroupIntervalId = undefined; 43 | } 44 | 45 | // handle all messages from Telegram 46 | export async function handleMessage(plugin: TelegramSyncPlugin, msg: TelegramBot.Message, isChannelPost = false) { 47 | if (!plugin.isBotConnected()) { 48 | plugin.setBotStatus("connected"); 49 | plugin.lastPollingErrors = []; 50 | } 51 | 52 | // if user disconnected and should be connected then reconnect it 53 | if (!plugin.userConnected) await enqueue(plugin, plugin.restartTelegram, "user"); 54 | 55 | const { fileObject, fileType } = getFileObject(msg); 56 | // skip system messages 57 | 58 | !isChannelPost && (await enqueue(ifNewReleaseThenShowChanges, plugin, msg)); 59 | 60 | if (!msg.text && !fileObject) { 61 | displayAndLog(plugin, `System message skipped`, 0); 62 | return; 63 | } 64 | let fileInfo = "binary"; 65 | if (fileType && fileObject) 66 | fileInfo = `${fileType} ${ 67 | fileObject instanceof Array ? fileObject[0]?.file_unique_id : fileObject.file_unique_id 68 | }`; 69 | 70 | // Skip processing if the message is a "/start" command 71 | // https://github.com/soberhacker/obsidian-telegram-sync/issues/109 72 | if (msg.text === "/start") { 73 | return; 74 | } 75 | 76 | // Store topic name if "/topicName " command 77 | if (msg.text?.includes("/topicName")) { 78 | await plugin.settingsTab?.storeTopicName(msg); 79 | return; 80 | } 81 | 82 | addOriginalUserMsg(msg); 83 | 84 | let msgText = (msg.text || msg.caption || fileInfo).replace("\n", ".."); 85 | 86 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 87 | if ((msg as any).userMsg) { 88 | displayAndLog(plugin, `Message skipped: already processed before!\n--- Message ---\n${msgText}\n<===`, 0); 89 | return; 90 | } 91 | 92 | const distributionRule = await getMessageDistributionRule(plugin, msg); 93 | if (msgText.length > 200) msgText = msgText.slice(0, 200) + "... (trimmed)"; 94 | if (!distributionRule) { 95 | displayAndLog(plugin, `Message skipped: no matched distribution rule!\n--- Message ---\n${msgText}\n<===`, 0); 96 | return; 97 | } else { 98 | const ruleInfo = getMessageDistributionRuleInfo(distributionRule); 99 | displayAndLog( 100 | plugin, 101 | `Message received\n--- Message ---\n${msgText}\n--- Distribution rule ---\n${JSON.stringify( 102 | ruleInfo, 103 | undefined, 104 | 4, 105 | )}\n<===`, 106 | 0, 107 | ); 108 | } 109 | 110 | // Check if message has been sended by allowed users or chats 111 | const telegramUserName = msg.from?.username ?? ""; 112 | const allowedChats = plugin.settings.allowedChats; 113 | 114 | if (!allowedChats.includes(telegramUserName) && !allowedChats.includes(msg.chat.id.toString())) { 115 | const telegramUserNameFull = telegramUserName ? `your username "${telegramUserName}" or` : ""; 116 | plugin.bot?.sendMessage( 117 | msg.chat.id, 118 | `Access denied. Add ${telegramUserNameFull} this chat id "${msg.chat.id}" in the plugin setting "Allowed Chats".`, 119 | { reply_to_message_id: msg.message_id }, 120 | ); 121 | return; 122 | } 123 | 124 | // save topic name and skip handling other data 125 | if (msg.forum_topic_created || msg.forum_topic_edited) { 126 | const topicName = { 127 | name: msg.forum_topic_created?.name || msg.forum_topic_edited?.name || "", 128 | chatId: msg.chat.id, 129 | topicId: msg.message_thread_id || 1, 130 | }; 131 | const topicNameIndex = plugin.settings.topicNames.findIndex( 132 | (tn) => tn.chatId == msg.chat.id && tn.topicId == msg.message_thread_id, 133 | ); 134 | if (topicNameIndex == -1) { 135 | plugin.settings.topicNames.push(topicName); 136 | await plugin.saveSettings(); 137 | } else if (plugin.settings.topicNames[topicNameIndex].name != topicName.name) { 138 | plugin.settings.topicNames[topicNameIndex].name = topicName.name; 139 | await plugin.saveSettings(); 140 | } 141 | return; 142 | } 143 | 144 | ++plugin.messagesLeftCnt; 145 | try { 146 | if (!msg.text && distributionRule.filePathTemplate) await handleFiles(plugin, msg, distributionRule); 147 | else await handleMessageText(plugin, msg, distributionRule); 148 | } catch (error) { 149 | await displayAndLogError(plugin, error, "", "", msg, _15sec); 150 | } finally { 151 | --plugin.messagesLeftCnt; 152 | if (plugin.messagesLeftCnt == 0 && canUpdateProcessingDate) { 153 | plugin.settings.processOldMessagesSettings.lastProcessingDate = getOffsetDate(); 154 | await plugin.saveSettings(); 155 | } 156 | } 157 | } 158 | 159 | export async function handleMessageText( 160 | plugin: TelegramSyncPlugin, 161 | msg: TelegramBot.Message, 162 | distributionRule: MessageDistributionRule, 163 | ) { 164 | const formattedContent = await applyNoteContentTemplate(plugin, distributionRule.templateFilePath, msg); 165 | const notePath = await applyNotePathTemplate(plugin, distributionRule.notePathTemplate, msg); 166 | 167 | let noteFolderPath = path.dirname(notePath); 168 | if (noteFolderPath != ".") createFolderIfNotExist(plugin.app.vault, noteFolderPath); 169 | else noteFolderPath = ""; 170 | 171 | await enqueue( 172 | appendContentToNote, 173 | plugin.app.vault, 174 | notePath, 175 | formattedContent, 176 | distributionRule.heading, 177 | plugin.settings.defaultMessageDelimiter ? defaultDelimiter : "", 178 | distributionRule.reversedOrder, 179 | ); 180 | await finalizeMessageProcessing(plugin, msg); 181 | } 182 | 183 | async function createNoteContent( 184 | plugin: TelegramSyncPlugin, 185 | notePath: string, 186 | msg: TelegramBot.Message, 187 | distributionRule: MessageDistributionRule, 188 | filesPaths: string[] = [], 189 | error?: Error, 190 | ) { 191 | const filesLinks: string[] = []; 192 | 193 | if (!error) { 194 | filesPaths.forEach((fp) => { 195 | const filePath = plugin.app.vault.getAbstractFileByPath(fp) as TFile; 196 | filesLinks.push(plugin.app.fileManager.generateMarkdownLink(filePath, notePath)); 197 | }); 198 | } else { 199 | filesLinks.push(`[❌ error while handling file](${error})`); 200 | } 201 | 202 | return await applyNoteContentTemplate(plugin, distributionRule.templateFilePath, msg, filesLinks); 203 | } 204 | 205 | // Handle files received in messages 206 | export async function handleFiles( 207 | plugin: TelegramSyncPlugin, 208 | msg: TelegramBot.Message, 209 | distributionRule: MessageDistributionRule, 210 | ) { 211 | if (!plugin.bot) return; 212 | let filePath = ""; 213 | let telegramFileName = ""; 214 | let error: Error | undefined = undefined; 215 | 216 | try { 217 | // Iterate through each file type 218 | const { fileType, fileObject } = getFileObject(msg); 219 | 220 | const fileObjectToUse = fileObject instanceof Array ? fileObject.pop() : fileObject; 221 | const fileId = fileObjectToUse.file_id; 222 | telegramFileName = ("file_name" in fileObjectToUse && fileObjectToUse.file_name) || ""; 223 | let fileByteArray: Uint8Array; 224 | try { 225 | const fileLink = await plugin.bot.getFileLink(fileId); 226 | const chatId = msg.chat.id < 0 ? msg.chat.id.toString().slice(4) : msg.chat.id.toString(); 227 | telegramFileName = 228 | telegramFileName || fileLink?.split("/").pop()?.replace(/file/, `${fileType}_${chatId}`) || ""; 229 | // TODO add bot file size limits to error "...file is too big..." (https://t.me/c/1536715535/1266) 230 | const fileStream = plugin.bot.getFileStream(fileId); 231 | const fileChunks: Uint8Array[] = []; 232 | 233 | if (!fileStream) { 234 | return; 235 | } 236 | 237 | const totalBytes = fileObjectToUse.file_size; 238 | let receivedBytes = 0; 239 | 240 | let stage = 0; 241 | // show progress bar only if file size > 3MB 242 | const progressBarMessage = 243 | totalBytes > _3MB ? await createProgressBar(plugin.bot, msg, ProgressBarType.DOWNLOADING) : undefined; 244 | try { 245 | for await (const chunk of fileStream) { 246 | fileChunks.push(new Uint8Array(chunk)); 247 | receivedBytes += chunk.length; 248 | stage = await updateProgressBar( 249 | plugin.bot, 250 | msg, 251 | progressBarMessage, 252 | totalBytes, 253 | receivedBytes, 254 | stage, 255 | ); 256 | } 257 | } finally { 258 | await deleteProgressBar(plugin.bot, msg, progressBarMessage); 259 | } 260 | 261 | fileByteArray = new Uint8Array( 262 | fileChunks.reduce((acc, val) => { 263 | acc.push(...val); 264 | return acc; 265 | }, []), 266 | ); 267 | } catch (e) { 268 | error = e; 269 | const media = await Client.downloadMedia( 270 | plugin.bot, 271 | msg, 272 | fileId, 273 | fileObjectToUse.file_size, 274 | plugin.botUser, 275 | ); 276 | fileByteArray = media instanceof Buffer ? media : Buffer.alloc(0); 277 | const chatId = msg.chat.id < 0 ? msg.chat.id.toString().slice(4) : msg.chat.id.toString(); 278 | telegramFileName = telegramFileName || `${fileType}_${chatId}_${msg.message_id}`; 279 | error = undefined; 280 | } 281 | telegramFileName = (msg.document && msg.document.file_name) || telegramFileName; 282 | const fileExtension = 283 | path.extname(telegramFileName).replace(".", "") || extension(fileObject.mime_type) || "file"; 284 | const fileName = path.basename(telegramFileName, "." + fileExtension); 285 | 286 | filePath = await applyFilesPathTemplate( 287 | plugin, 288 | distributionRule.filePathTemplate, 289 | msg, 290 | fileType, 291 | fileExtension, 292 | fileName, 293 | ); 294 | 295 | filePath = await enqueue( 296 | getUniqueFilePath, 297 | plugin.app.vault, 298 | plugin.createdFilePaths, 299 | filePath, 300 | unixTime2Date(msg.date, msg.message_id), 301 | fileExtension, 302 | ); 303 | await plugin.app.vault.createBinary(filePath, fileByteArray); 304 | } catch (e) { 305 | if (error) (error as Error).message = (error as Error).message + " | " + e; 306 | else error = e; 307 | } 308 | 309 | if (msg.caption || distributionRule.templateFilePath) 310 | await appendFileToNote(plugin, msg, distributionRule, filePath, error); 311 | 312 | if (msg.media_group_id && !handleMediaGroupIntervalId) 313 | handleMediaGroupIntervalId = setInterval( 314 | async () => await enqueue(handleMediaGroup, plugin, distributionRule), 315 | _1sec, 316 | ); 317 | else if (!msg.media_group_id) await finalizeMessageProcessing(plugin, msg, error); 318 | } 319 | 320 | async function handleMediaGroup(plugin: TelegramSyncPlugin, distributionRule: MessageDistributionRule) { 321 | if (mediaGroups.length > 0 && plugin.messagesLeftCnt == 0) { 322 | for (const mg of mediaGroups) { 323 | try { 324 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 325 | (mg.initialMsg as any).mediaMessages = mg.mediaMessages; 326 | const noteContent = await createNoteContent( 327 | plugin, 328 | mg.notePath, 329 | mg.initialMsg, 330 | distributionRule, 331 | mg.filesPaths, 332 | mg.error, 333 | ); 334 | await enqueue( 335 | appendContentToNote, 336 | plugin.app.vault, 337 | mg.notePath, 338 | noteContent, 339 | distributionRule.heading, 340 | plugin.settings.defaultMessageDelimiter ? defaultDelimiter : "", 341 | distributionRule.reversedOrder, 342 | ); 343 | await finalizeMessageProcessing(plugin, mg.initialMsg, mg.error); 344 | } catch (e) { 345 | displayAndLogError(plugin, e, "", "", mg.initialMsg, 0); 346 | } finally { 347 | mediaGroups.remove(mg); 348 | } 349 | } 350 | } 351 | } 352 | 353 | async function appendFileToNote( 354 | plugin: TelegramSyncPlugin, 355 | msg: TelegramBot.Message, 356 | distributionRule: MessageDistributionRule, 357 | filePath: string, 358 | error?: Error, 359 | ) { 360 | let mediaGroup = mediaGroups.find((mg) => mg.id == msg.media_group_id); 361 | if (mediaGroup) { 362 | mediaGroup.filesPaths.push(filePath); 363 | if (msg.caption || !mediaGroup.initialMsg) mediaGroup.initialMsg = msg; 364 | mediaGroup.mediaMessages.push(msg); 365 | if (error) mediaGroup.error = error; 366 | return; 367 | } 368 | 369 | const notePath = await applyNotePathTemplate(plugin, distributionRule.notePathTemplate, msg); 370 | 371 | let noteFolderPath = path.dirname(notePath); 372 | if (noteFolderPath != ".") createFolderIfNotExist(plugin.app.vault, noteFolderPath); 373 | else noteFolderPath = ""; 374 | 375 | if (msg.media_group_id) { 376 | mediaGroup = { 377 | id: msg.media_group_id, 378 | notePath, 379 | initialMsg: msg, 380 | mediaMessages: [], 381 | error: error, 382 | filesPaths: [filePath], 383 | }; 384 | mediaGroups.push(mediaGroup); 385 | return; 386 | } 387 | 388 | const noteContent = await createNoteContent(plugin, notePath, msg, distributionRule, [filePath], error); 389 | 390 | await enqueue( 391 | appendContentToNote, 392 | plugin.app.vault, 393 | notePath, 394 | noteContent, 395 | distributionRule.heading, 396 | plugin.settings.defaultMessageDelimiter ? defaultDelimiter : "", 397 | distributionRule.reversedOrder, 398 | ); 399 | } 400 | 401 | // show changes about new release 402 | export async function ifNewReleaseThenShowChanges(plugin: TelegramSyncPlugin, msg: TelegramBot.Message) { 403 | if (plugin.settings.pluginVersion == release.releaseVersion) return; 404 | 405 | plugin.settings.pluginVersion = release.releaseVersion; 406 | await plugin.saveSettings(); 407 | 408 | if (plugin.userConnected && (await Client.subscribedOnInsiderChannel())) return; 409 | 410 | if (plugin.settings.pluginVersion && release.showNewFeatures) { 411 | const options: SendMessageOptions = { 412 | parse_mode: "HTML", 413 | reply_markup: { inline_keyboard: donationInlineKeyboard }, 414 | }; 415 | await plugin.bot?.sendMessage(msg.chat.id, release.notes, options); 416 | } 417 | 418 | if (plugin.settings.pluginVersion && release.showBreakingChanges && !plugin.userConnected) { 419 | await plugin.bot?.sendMessage(msg.chat.id, release.breakingChanges, { parse_mode: "HTML" }); 420 | } 421 | } 422 | -------------------------------------------------------------------------------- /src/telegram/bot/message/processors.ts: -------------------------------------------------------------------------------- 1 | import TelegramBot from "node-telegram-bot-api"; 2 | import TelegramSyncPlugin from "../../../main"; 3 | import { 4 | getChatId, 5 | getChatLink, 6 | getChatName, 7 | getForwardFromLink, 8 | getForwardFromName, 9 | getHashtag, 10 | getReplyMessageId, 11 | getTopic, 12 | getTopicId, 13 | getTopicLink, 14 | getUrl, 15 | getUserLink, 16 | } from "./getters"; 17 | import { TFile, normalizePath } from "obsidian"; 18 | import { formatDateTime, unixTime2Date } from "../../../utils/dateUtils"; 19 | import { _15sec, _1h, _5sec, displayAndLog, displayAndLogError } from "src/utils/logUtils"; 20 | import { convertMessageTextToMarkdown, escapeRegExp } from "./convertToMarkdown"; 21 | import * as Client from "../../user/client"; 22 | import { enqueue } from "src/utils/queues"; 23 | import { sanitizeFileName, sanitizeFilePath } from "src/utils/fsUtils"; 24 | import path from "path"; 25 | import { defaultFileNameTemplate, defaultNoteNameTemplate } from "src/settings/messageDistribution"; 26 | import { Api } from "telegram"; 27 | import { setReaction } from "../bot"; 28 | import { emoticonProcessed, emoticonProcessedEdited } from "src/telegram/user/config"; 29 | 30 | // Delete a message or send a confirmation reply based on settings and message age 31 | export async function finalizeMessageProcessing(plugin: TelegramSyncPlugin, msg: TelegramBot.Message, error?: Error) { 32 | if (error) await displayAndLogError(plugin, error, "", "", msg, _5sec); 33 | if (error || !plugin.bot) { 34 | return; 35 | } 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | const originalMsg: Api.Message | undefined = (msg as any).originalUserMsg; 38 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 39 | const mediaMessages: TelegramBot.Message[] = (msg as any).mediaMessages || []; 40 | 41 | if (originalMsg) { 42 | await plugin.bot.deleteMessage(msg.chat.id, msg.message_id); 43 | } 44 | 45 | const messageTime = unixTime2Date(msg.date); 46 | const timeDifference = new Date().getTime() - messageTime.getTime(); 47 | const hoursDifference = timeDifference / _1h; 48 | 49 | if (plugin.settings.deleteMessagesFromTelegram && originalMsg) { 50 | await originalMsg.delete(); 51 | } else if (plugin.settings.deleteMessagesFromTelegram && hoursDifference <= 24) { 52 | for (const mediaMsg of mediaMessages) { 53 | await plugin.bot.deleteMessage(mediaMsg.chat.id, mediaMsg.message_id); 54 | } 55 | await plugin.bot.deleteMessage(msg.chat.id, msg.message_id); 56 | } else { 57 | let needReply = true; 58 | let errorMessage = ""; 59 | 60 | const emoticon = msg.edit_date ? emoticonProcessedEdited : emoticonProcessed; 61 | // reacting by bot 62 | try { 63 | await enqueue(setReaction, plugin, msg, emoticon); 64 | needReply = false; 65 | } catch (e) { 66 | errorMessage = `\n\nCan't "like" the message by bot, ${e}`; 67 | } 68 | // reacting by user 69 | try { 70 | if (needReply && plugin.settings.telegramSessionType == "user" && plugin.botUser) { 71 | await enqueue(Client.sendReaction, plugin.botUser, msg, emoticon); 72 | needReply = false; 73 | } 74 | } catch (e) { 75 | errorMessage = `\n\nCan't "like" the message by user, ${e}`; 76 | } 77 | const ok_msg = msg.edit_date ? "...🆗..." : "...✅..."; 78 | if (needReply && originalMsg) { 79 | await originalMsg.reply({ 80 | message: ok_msg + errorMessage, 81 | silent: true, 82 | }); 83 | } else if (needReply) { 84 | await plugin.bot?.sendMessage(msg.chat.id, ok_msg + errorMessage, { 85 | reply_to_message_id: msg.message_id, 86 | disable_notification: true, 87 | }); 88 | } 89 | } 90 | } 91 | 92 | // Apply a template to a message's content 93 | export async function applyNoteContentTemplate( 94 | plugin: TelegramSyncPlugin, 95 | templateFilePath: string, 96 | msg: TelegramBot.Message, 97 | filesLinks: string[] = [], 98 | ): Promise { 99 | let templateContent = ""; 100 | try { 101 | if (templateFilePath) { 102 | const templateFile = plugin.app.vault.getAbstractFileByPath(normalizePath(templateFilePath)) as TFile; 103 | templateContent = await plugin.app.vault.read(templateFile); 104 | } 105 | } catch (e) { 106 | throw new Error(`Template "${templateFilePath}" not found! ${e}`); 107 | } 108 | 109 | const allEmbeddedFilesLinks = filesLinks.length > 0 ? filesLinks.join("\n") : ""; 110 | const allFilesLinks = allEmbeddedFilesLinks.replace("![", "["); 111 | let textContentMd = ""; 112 | if (!templateContent || templateContent.includes("{{content")) 113 | textContentMd = await convertMessageTextToMarkdown(msg); 114 | // Check if the message is forwarded and extract the required information 115 | const forwardFromLink = getForwardFromLink(msg); 116 | const fullContent = 117 | (forwardFromLink ? `**Forwarded from ${forwardFromLink}**\n\n` : "") + 118 | (allEmbeddedFilesLinks ? allEmbeddedFilesLinks + "\n\n" : "") + 119 | textContentMd; 120 | 121 | if (!templateContent) { 122 | return fullContent; 123 | } 124 | 125 | const itemsForReplacing: [string, string][] = []; 126 | 127 | let processedContent = ( 128 | await processBasicVariables(plugin, msg, templateContent, textContentMd, fullContent, false) 129 | ) 130 | .replace(/{{files}}/g, allEmbeddedFilesLinks) 131 | .replace(/{{files:links}}/g, allFilesLinks) 132 | .replace(/{{url1}}/g, getUrl(msg)) // first url from the message 133 | .replace(/{{url1:preview(.*?)}}/g, (_, height: string) => { 134 | let linkPreview = ""; 135 | const url1 = getUrl(msg); 136 | if (url1) { 137 | if (!height || Number.isInteger(parseFloat(height))) { 138 | linkPreview = ``; 139 | } else { 140 | displayAndLog(plugin, `Template variable {{url1:preview${height}}} isn't supported!`, _15sec); 141 | } 142 | } 143 | return linkPreview; 144 | }) // preview for first url from the message 145 | .replace(/{{replace:(.*?)=>(.*?)}}/g, (_, replaceThis, replaceWith) => { 146 | itemsForReplacing.push([replaceThis, replaceWith]); 147 | return ""; 148 | }) 149 | .replace(/{{replace:(.*?)}}/g, (_, replaceThis) => { 150 | itemsForReplacing.push([replaceThis, ""]); 151 | return ""; 152 | }); 153 | 154 | itemsForReplacing.forEach(([replaceThis, replaceWith]) => { 155 | const beautyReplaceThis = escapeRegExp(replaceThis).replace(/\\\\n/g, "\\n"); 156 | const beautyReplaceWith = replaceWith.replace(/\\n/g, "\n"); 157 | processedContent = processedContent.replace(new RegExp(beautyReplaceThis, "g"), beautyReplaceWith); 158 | }); 159 | return processedContent; 160 | } 161 | 162 | export async function applyNotePathTemplate( 163 | plugin: TelegramSyncPlugin, 164 | notePathTemplate: string, 165 | msg: TelegramBot.Message, 166 | ): Promise { 167 | if (!notePathTemplate) return ""; 168 | 169 | let processedPath = notePathTemplate.endsWith("/") ? notePathTemplate + defaultNoteNameTemplate : notePathTemplate; 170 | let textContentMd = ""; 171 | if (processedPath.includes("{{content")) textContentMd = msg.text || msg.caption || ""; 172 | processedPath = await processBasicVariables(plugin, msg, processedPath, textContentMd); 173 | if (processedPath.endsWith("/.md")) processedPath = processedPath.replace("/.md", "/_.md"); 174 | if (!path.extname(processedPath)) processedPath = processedPath + ".md"; 175 | if (processedPath.endsWith(".")) processedPath = processedPath + "md"; 176 | return sanitizeFilePath(processedPath); 177 | } 178 | 179 | export async function applyFilesPathTemplate( 180 | plugin: TelegramSyncPlugin, 181 | filePathTemplate: string, 182 | msg: TelegramBot.Message, 183 | fileType: string, 184 | fileExtension: string, 185 | fileName: string, 186 | ): Promise { 187 | if (!filePathTemplate) return ""; 188 | 189 | let processedPath = filePathTemplate.endsWith("/") ? filePathTemplate + defaultFileNameTemplate : filePathTemplate; 190 | processedPath = await processBasicVariables(plugin, msg, processedPath, msg.caption); 191 | processedPath = processedPath 192 | .replace(/{{file:type}}/g, fileType) 193 | .replace(/{{file:name}}/g, fileName) 194 | .replace(/{{file:extension}}/g, fileExtension); 195 | if (!path.extname(processedPath)) processedPath = processedPath + "." + fileExtension; 196 | if (processedPath.endsWith(".")) processedPath = processedPath + fileExtension; 197 | return sanitizeFilePath(processedPath); 198 | } 199 | 200 | // Apply a template to a message's content 201 | export async function processBasicVariables( 202 | plugin: TelegramSyncPlugin, 203 | msg: TelegramBot.Message, 204 | processThis: string, 205 | messageText?: string, 206 | messageContent?: string, 207 | isPath = true, 208 | ): Promise { 209 | const dateTimeNow = new Date(); 210 | const messageDateTime = unixTime2Date(msg.date, msg.message_id); 211 | const creationDateTime = msg.forward_date ? unixTime2Date(msg.forward_date, msg.message_id) : messageDateTime; 212 | 213 | let voiceTranscript = ""; 214 | if (processThis.includes("{{voiceTranscript") && plugin.bot) { 215 | voiceTranscript = await Client.transcribeAudio(plugin.bot, msg, await plugin.getBotUser()); 216 | } 217 | 218 | const lines = processThis.split("\n"); 219 | for (let i = 0; i < lines.length; i++) { 220 | let line = lines[i]; 221 | 222 | if (line.includes("{{content")) { 223 | lines[i] = pasteText( 224 | plugin, 225 | "content", 226 | line, 227 | messageContent || messageText || "", 228 | messageText || "", 229 | isPath, 230 | ); 231 | line = lines[i]; 232 | } 233 | 234 | if (line.includes("{{voiceTranscript")) { 235 | lines[i] = pasteText(plugin, "voiceTranscript", line, voiceTranscript, voiceTranscript, isPath); 236 | } 237 | } 238 | let processedContent = lines.join("\n"); 239 | 240 | processedContent = processedContent 241 | .replace(/{{messageDate:(.*?)}}/g, (_, format) => formatDateTime(messageDateTime, format)) 242 | .replace(/{{messageTime:(.*?)}}/g, (_, format) => formatDateTime(messageDateTime, format)) 243 | .replace(/{{date:(.*?)}}/g, (_, format) => formatDateTime(dateTimeNow, format)) 244 | .replace(/{{time:(.*?)}}/g, (_, format) => formatDateTime(dateTimeNow, format)) 245 | .replace(/{{forwardFrom}}/g, getForwardFromLink(msg)) 246 | .replace(/{{forwardFrom:name}}/g, prepareIfPath(isPath, getForwardFromName(msg))) // name of forwarded message creator 247 | .replace(/{{user}}/g, getUserLink(msg)) // link to the user who sent the message 248 | .replace(/{{user:name}}/g, prepareIfPath(isPath, msg.from?.username || "")) 249 | .replace( 250 | /{{user:fullName}}/g, 251 | prepareIfPath(isPath, `${msg.from?.first_name} ${msg.from?.last_name || ""}`.trim()), 252 | ) 253 | .replace(/{{userId}}/g, msg.from?.id.toString() || msg.message_id.toString()) // id of the user who sent the message 254 | .replace(/{{chat}}/g, getChatLink(msg, plugin.botUser)) // link to the chat with the message 255 | .replace(/{{chatId}}/g, getChatId(msg, plugin.botUser)) // id of the chat with the message 256 | .replace(/{{chat:name}}/g, prepareIfPath(isPath, getChatName(msg, plugin.botUser))) // name of the chat (bot / group / channel) 257 | .replace(/{{topic}}/g, await getTopicLink(plugin, msg)) // link to the topic with the message 258 | .replace(/{{topic:name}}/g, prepareIfPath(isPath, (await getTopic(plugin, msg))?.name || "")) // link to the topic with the message 259 | .replace(/{{topicId}}/g, getTopicId(msg)?.toString() || "") // head message id representing the topic 260 | .replace(/{{messageId}}/g, msg.message_id.toString()) 261 | .replace(/{{replyMessageId}}/g, getReplyMessageId(msg)) 262 | .replace(/{{hashtag:\[(\d+)\]}}/g, (_, num) => getHashtag(msg, num)) 263 | .replace(/{{creationDate:(.*?)}}/g, (_, format) => formatDateTime(creationDateTime, format)) // date, when the message was created 264 | .replace(/{{creationTime:(.*?)}}/g, (_, format) => formatDateTime(creationDateTime, format)); // time, when the message was created 265 | return processedContent; 266 | } 267 | 268 | function prepareIfPath(isPath: boolean, value: string): string { 269 | return isPath ? sanitizeFileName(value) : value; 270 | } 271 | 272 | // Copy tab and blockquotes to every new line of {{content*}} or {{voiceTranscript*}} if they are placed in front of this variables. 273 | // https://github.com/soberhacker/obsidian-telegram-sync/issues/131 274 | function addLeadingForEveryLine(text: string, leadingChars?: string): string { 275 | if (!leadingChars) return text; 276 | return text 277 | .split("\n") 278 | .map((line) => leadingChars + line) 279 | .join("\n"); 280 | } 281 | 282 | function processText(text: string, leadingChars?: string, property?: string): string { 283 | let finalText = ""; 284 | const lowerCaseProperty = (property && property.toLowerCase()) || "text"; 285 | 286 | if (lowerCaseProperty == "text") finalText = text; 287 | // if property is length 288 | else if (Number.isInteger(parseFloat(lowerCaseProperty))) finalText = text.substring(0, Number(property)); 289 | 290 | if (finalText) return addLeadingForEveryLine(finalText, leadingChars); 291 | 292 | // if property is range 293 | const rangePattern = /^\[\d+-\d+\]$/; 294 | const singleLinePattern = /^\[\d+\]$/; 295 | const lastLinePattern = /^\[-\d+\]$/; 296 | const fromLineToEndPattern = /^\[\d+-\]$/; 297 | 298 | let lines = text.split("\n"); 299 | let startLine = 0; 300 | let endLine = lines.length; 301 | 302 | if (rangePattern.test(lowerCaseProperty)) { 303 | const range = lowerCaseProperty 304 | .substring(1, lowerCaseProperty.length - 1) 305 | .split("-") 306 | .map(Number); 307 | startLine = Math.max(0, range[0] - 1); 308 | endLine = Math.min(lines.length, range[1]); 309 | } else if (singleLinePattern.test(lowerCaseProperty)) { 310 | startLine = Number(lowerCaseProperty.substring(1, lowerCaseProperty.length - 1)) - 1; 311 | endLine = startLine + 1; 312 | } else if (lastLinePattern.test(lowerCaseProperty)) { 313 | startLine = Math.max( 314 | 0, 315 | lines.length - Number(lowerCaseProperty.substring(2, lowerCaseProperty.length - 1)) - 1, 316 | ); 317 | endLine = startLine + 1; 318 | } else if (fromLineToEndPattern.test(lowerCaseProperty)) { 319 | startLine = Number(lowerCaseProperty.substring(1, lowerCaseProperty.length - 2)) - 1; 320 | endLine = lines.length; 321 | } else lines = []; 322 | 323 | finalText = lines.slice(startLine, endLine).join("\n"); 324 | 325 | return addLeadingForEveryLine(finalText, leadingChars); 326 | } 327 | 328 | function pasteText( 329 | plugin: TelegramSyncPlugin, 330 | pasteType: "content" | "voiceTranscript", 331 | pasteHere: string, 332 | content: string, 333 | text: string, 334 | isPath: boolean, 335 | ) { 336 | const leadingRE = new RegExp(`^([>\\s]+){{${pasteType}}}`); 337 | const leadingAndPropertyRE = new RegExp(`^([>\\s]+){{${pasteType}:(.*?)}}`); 338 | const propertyRE = new RegExp(`{{${pasteType}:(.*?)}}`, "g"); 339 | const allRE = new RegExp(`{{${pasteType}}}`, "g"); 340 | return pasteHere 341 | .replace(leadingRE, (_, leadingChars) => prepareIfPath(isPath, processText(content, leadingChars))) 342 | .replace(leadingAndPropertyRE, (_, leadingChars, property) => { 343 | const processedText = processText(text, leadingChars, property); 344 | if (!processedText && property && text) { 345 | displayAndLog(plugin, `Template variable {{${pasteType}}:${property}}} isn't supported!`, _5sec); 346 | } 347 | return prepareIfPath(isPath, processedText); 348 | }) 349 | .replace(allRE, prepareIfPath(isPath, content)) 350 | .replace(propertyRE, (_, property: string) => prepareIfPath(isPath, processText(text, undefined, property))); 351 | } 352 | -------------------------------------------------------------------------------- /src/telegram/bot/progressBar.ts: -------------------------------------------------------------------------------- 1 | import TelegramBot from "node-telegram-bot-api"; 2 | import { checkIfTooManyRequests, isTooManyRequests } from "./tooManyRequests"; 3 | 4 | export enum ProgressBarType { 5 | DOWNLOADING = "downloading", 6 | DELETING = "deleting", 7 | STORED = "stored", 8 | TRANSCRIBING = "transcribing", 9 | } 10 | 11 | export const _3MB = 3 * 1024 * 1024; 12 | 13 | export async function createProgressBar( 14 | bot: TelegramBot, 15 | msg: TelegramBot.Message, 16 | action: ProgressBarType, 17 | ): Promise { 18 | return await bot.sendMessage(msg.chat.id, action, { 19 | reply_to_message_id: msg.message_id, 20 | reply_markup: { inline_keyboard: createProgressBarKeyboard(0).inline_keyboard }, 21 | disable_notification: true, 22 | }); 23 | } 24 | 25 | // redraw the progress bar to current process state 26 | export async function updateProgressBar( 27 | bot: TelegramBot, 28 | msg: TelegramBot.Message, 29 | progressBarMessage: TelegramBot.Message | undefined, 30 | total: number, 31 | current: number, 32 | previousStage: number, 33 | ): Promise { 34 | if (!progressBarMessage) return 0; 35 | const stage = Math.ceil((current / total) * 10); 36 | if (previousStage == stage || isTooManyRequests) return stage; 37 | try { 38 | await bot.editMessageReplyMarkup( 39 | { 40 | inline_keyboard: createProgressBarKeyboard(stage).inline_keyboard, 41 | }, 42 | { chat_id: msg.chat.id, message_id: progressBarMessage.message_id }, 43 | ); 44 | } catch (e) { 45 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 46 | if (!checkIfTooManyRequests(e)) console.log(`Telegram Sync => ${e}`); 47 | } 48 | return stage; 49 | } 50 | 51 | export async function deleteProgressBar( 52 | bot: TelegramBot, 53 | msg: TelegramBot.Message, 54 | progressBarMessage: TelegramBot.Message | undefined, 55 | ) { 56 | if (!progressBarMessage) return; 57 | await bot.deleteMessage(msg.chat.id, progressBarMessage.message_id); 58 | } 59 | // Create a progress bar keyboard 60 | function createProgressBarKeyboard(progress: number) { 61 | const progressBar = "▓".repeat(progress) + "░".repeat(10 - progress); 62 | return { 63 | inline_keyboard: [ 64 | [ 65 | { 66 | text: progressBar, 67 | callback_data: JSON.stringify({ action: "update_progress", progress: progress }), 68 | }, 69 | ], 70 | ], 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /src/telegram/bot/tooManyRequests.ts: -------------------------------------------------------------------------------- 1 | import { _5sec } from "src/utils/logUtils"; 2 | 3 | export let isTooManyRequests = false; 4 | // reset isTooManyRequests 5 | const tooManyRequestsIntervalId = setInterval(() => { 6 | isTooManyRequests = false; 7 | }, _5sec); 8 | 9 | export function clearTooManyRequestsInterval() { 10 | clearInterval(tooManyRequestsIntervalId); 11 | } 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | export function checkIfTooManyRequests(error: any): boolean { 15 | try { 16 | const errorCode = error.response.body.error_code; 17 | isTooManyRequests = errorCode == 429; 18 | return isTooManyRequests; 19 | } catch { 20 | return false; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/telegram/convertors/botFileToMessageMedia.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import bigInt from "big-integer"; 3 | import { Api } from "telegram"; 4 | import { Buffer } from "buffer"; 5 | 6 | const WEB_LOCATION_FLAG = 1 << 24; // substitute with actual value 7 | const FILE_REFERENCE_FLAG = 1 << 25; // substitute with actual value 8 | 9 | enum FileType { 10 | THUMBNAIL = 0, 11 | CHAT_PHOTO = 1, // ProfilePhoto 12 | PHOTO = 2, 13 | VOICE = 3, // VoiceNote 14 | VIDEO = 4, 15 | DOCUMENT = 5, 16 | ENCRYPTED = 6, 17 | TEMP = 7, 18 | STICKER = 8, 19 | AUDIO = 9, 20 | ANIMATION = 10, 21 | ENCRYPTED_THUMBNAIL = 11, 22 | WALLPAPER = 12, 23 | VIDEO_NOTE = 13, 24 | SECURE_RAW = 14, 25 | SECURE = 15, 26 | BACKGROUND = 16, 27 | DOCUMENT_AS_FILE = 17, 28 | } 29 | 30 | // Photo-like file ids are longer and contain extra info, the rest are all documents 31 | const PHOTO_TYPES = new Set([ 32 | FileType.THUMBNAIL, 33 | FileType.CHAT_PHOTO, 34 | FileType.PHOTO, 35 | FileType.WALLPAPER, 36 | FileType.ENCRYPTED_THUMBNAIL, 37 | ]); 38 | 39 | const DOCUMENT_TYPES = new Set(Object.values(FileType)); 40 | 41 | for (const item of PHOTO_TYPES) { 42 | DOCUMENT_TYPES.delete(item); 43 | } 44 | 45 | // converting Telegram Bot Api file_id to Telegram Client Api media object 46 | export function convertBotFileToMessageMedia(fileId: string, fileSize: number): Api.TypeMessageMedia { 47 | const decoded = rle_decode(b64_decode(fileId)); 48 | const major = decoded[decoded.length - 1]; 49 | const buffer = major < 4 ? decoded.slice(0, -1) : decoded.slice(0, -2); 50 | 51 | let bufferPosition = 0; 52 | let fileType = buffer.readInt32LE(bufferPosition); 53 | bufferPosition += 4; 54 | const dcId = buffer.readInt32LE(bufferPosition); 55 | bufferPosition += 4; 56 | 57 | const hasWebLocation = Boolean(fileType & WEB_LOCATION_FLAG); 58 | const hasFileReference = Boolean(fileType & FILE_REFERENCE_FLAG); 59 | 60 | fileType &= ~WEB_LOCATION_FLAG; 61 | fileType &= ~FILE_REFERENCE_FLAG; 62 | 63 | if (!(fileType in FileType)) { 64 | throw new Error(`Unknown file_type ${fileType} of file_id ${fileId}`); 65 | } 66 | 67 | if (hasWebLocation) { 68 | const { result: url, newPosition } = readString(buffer, bufferPosition); 69 | bufferPosition = newPosition; 70 | 71 | const accessHash = buffer.readBigInt64LE(bufferPosition); 72 | bufferPosition += 8; 73 | 74 | // Fake type to return url 75 | const webpage = new Api.WebPage({ 76 | id: bigInt(accessHash), 77 | hash: Number(accessHash.toString), 78 | url: url, 79 | displayUrl: url, 80 | attributes: [], 81 | }); 82 | 83 | return new Api.MessageMediaWebPage({ 84 | webpage: webpage, 85 | }); 86 | } 87 | 88 | let fileReference: Buffer; 89 | if (hasFileReference) { 90 | const { result, newPosition } = readBytes(buffer, bufferPosition); 91 | fileReference = result; 92 | bufferPosition = newPosition; 93 | } else { 94 | fileReference = Buffer.alloc(0); 95 | } 96 | 97 | const media_id = BigInt(buffer.readBigInt64LE(bufferPosition).toString()); 98 | bufferPosition += 8; 99 | const access_hash = BigInt(buffer.readBigInt64LE(bufferPosition).toString()); 100 | bufferPosition += 8; 101 | 102 | if (PHOTO_TYPES.has(fileType)) { 103 | const photo = new Api.Photo({ 104 | id: bigInt(media_id), 105 | accessHash: bigInt(access_hash), 106 | fileReference: fileReference, 107 | dcId: dcId, 108 | date: 0, 109 | sizes: [], 110 | }); 111 | 112 | return new Api.MessageMediaPhoto({ 113 | photo: photo, 114 | }); 115 | } 116 | 117 | const document = new Api.Document({ 118 | id: bigInt(media_id), 119 | accessHash: bigInt(access_hash), 120 | mimeType: "", 121 | date: 0, 122 | size: bigInt(fileSize), 123 | dcId: dcId, 124 | fileReference: fileReference, 125 | attributes: [], 126 | }); 127 | 128 | return new Api.MessageMediaDocument({ 129 | document: document, 130 | }); 131 | } 132 | 133 | // converting Telegram Bot Api file_id to Telegram Client Api media object 134 | export function extractMediaId(fileId: string): string { 135 | const decoded = rle_decode(b64_decode(fileId)); 136 | const major = decoded[decoded.length - 1]; 137 | const buffer = major < 4 ? decoded.slice(0, -1) : decoded.slice(0, -2); 138 | 139 | let bufferPosition = 0; 140 | let fileType = buffer.readInt32LE(bufferPosition); 141 | bufferPosition += 4; 142 | buffer.readInt32LE(bufferPosition); 143 | bufferPosition += 4; 144 | 145 | const hasFileReference = Boolean(fileType & FILE_REFERENCE_FLAG); 146 | 147 | fileType &= ~WEB_LOCATION_FLAG; 148 | fileType &= ~FILE_REFERENCE_FLAG; 149 | 150 | if (!(fileType in FileType)) { 151 | throw new Error(`Unknown file_type ${fileType} of file_id ${fileId}`); 152 | } 153 | 154 | if (hasFileReference) { 155 | const { newPosition } = readBytes(buffer, bufferPosition); 156 | bufferPosition = newPosition; 157 | } 158 | 159 | const mediaId = buffer.readBigInt64LE(bufferPosition); 160 | return mediaId.toString(); 161 | } 162 | 163 | function b64_decode(s: string): Buffer { 164 | const base64Padded = s + "=".repeat(mod(-s.length, 4)); 165 | return Buffer.from(base64Padded, "base64"); 166 | } 167 | 168 | function rle_decode(s: Buffer): Buffer { 169 | const r: number[] = []; 170 | let z = false; 171 | 172 | for (let i = 0; i < s.length; i++) { 173 | const b = s[i]; 174 | 175 | if (!b) { 176 | z = true; 177 | continue; 178 | } 179 | 180 | if (z) { 181 | r.push(...Array(b).fill(0)); 182 | z = false; 183 | } else { 184 | r.push(b); 185 | } 186 | } 187 | 188 | return Buffer.from(r); 189 | } 190 | 191 | function readBytes(buffer: Buffer, position: number): { result: Buffer; newPosition: number } { 192 | let length = buffer.readUInt8(position); 193 | position += 1; 194 | let padding = 0; 195 | 196 | if (length > 253) { 197 | length = buffer.readUIntLE(position, 3); 198 | position += 3; 199 | padding = mod(-length, 4); 200 | } else { 201 | padding = mod(-(length + 1), 4); 202 | } 203 | 204 | const result = buffer.slice(position, position + length); 205 | position += length + padding; 206 | return { result, newPosition: position }; 207 | } 208 | 209 | function readString(buffer: Buffer, position: number): { result: string; newPosition: number } { 210 | const { result, newPosition } = readBytes(buffer, position); 211 | return { result: result.toString("utf8"), newPosition }; 212 | } 213 | 214 | function mod(n: number, m: number): number { 215 | return ((n % m) + m) % m; 216 | } 217 | -------------------------------------------------------------------------------- /src/telegram/convertors/botMessageToClientMessage.ts: -------------------------------------------------------------------------------- 1 | import TelegramBot from "node-telegram-bot-api"; 2 | import { Api, TelegramClient } from "telegram"; 3 | import { getFileObject } from "../bot/message/getters"; 4 | import { extractMediaId } from "./botFileToMessageMedia"; 5 | import { TotalList } from "telegram/Helpers"; 6 | import { _1h, _1sec, _2h } from "src/utils/logUtils"; 7 | import { unixTime2Date } from "src/utils/dateUtils"; 8 | 9 | const cantFindTheMessage = "Can't find the message for connected user."; 10 | 11 | interface MessageCouple { 12 | date: number; 13 | creationTime: Date; 14 | botMsgId: number; 15 | userMsg: Api.Message; 16 | } 17 | 18 | interface MessagesRequests { 19 | botChatId: number; 20 | msgDate: number; 21 | limit: number; 22 | messages: TotalList; 23 | } 24 | 25 | interface UserCouple { 26 | userId: number; 27 | botUserId: number; 28 | botChatId: number; 29 | userChat: Api.TypeInputPeer; 30 | } 31 | 32 | let cachedMessageCouples: MessageCouple[] = []; 33 | let cachedMessagesRequests: MessagesRequests[] = []; 34 | const cachedUserCouples: UserCouple[] = []; 35 | 36 | // clean every 2 hours message request and couples if needed 37 | const cachedMessagesIntervalId = setInterval(() => { 38 | if (cachedMessageCouples.length < 5000) return; 39 | const lastMessageCouple = cachedMessageCouples.last(); 40 | if (lastMessageCouple && new Date().getTime() - lastMessageCouple.creationTime.getTime() > _1h) { 41 | cachedMessagesRequests = []; 42 | cachedMessageCouples = []; 43 | } 44 | }, _2h); 45 | 46 | export function clearCachedMessagesInterval() { 47 | clearInterval(cachedMessagesIntervalId); 48 | } 49 | 50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 51 | function getMediaId(media: any): bigint | undefined { 52 | if (!media) return undefined; 53 | if (media.document && media.document.id) return media.document.id; 54 | if (media.photo && media.photo.id) return media.photo.id; 55 | return undefined; 56 | } 57 | 58 | export async function getInputPeer( 59 | client: TelegramClient, 60 | user: Api.User, 61 | botUser: TelegramBot.User, 62 | botMsg: TelegramBot.Message, 63 | limit = 10, 64 | ): Promise { 65 | let userCouple = cachedUserCouples.find( 66 | (usrCouple) => 67 | usrCouple.botChatId == botMsg.chat.id && 68 | usrCouple.botUserId == botUser.id && 69 | usrCouple.userId == user.id.toJSNumber(), 70 | ); 71 | if (userCouple) return userCouple.userChat; 72 | 73 | const chatId = botMsg.chat.id == user.id.toJSNumber() ? botUser.id : botMsg.chat.id; 74 | const dialogs = await client.getDialogs({ limit }); 75 | const dialog = dialogs.find((d) => d.id?.toJSNumber() == chatId); 76 | if (!dialog && limit <= 20) return await getInputPeer(client, user, botUser, botMsg, limit + 10); 77 | else if (!dialog || !dialog.inputEntity) { 78 | console.log(`Telegram Sync => Dialogs:\n${dialogs}`); 79 | throw new Error( 80 | `User ${user.username || user.firstName || user.id} does not have chat with ${ 81 | botMsg.chat.username || botMsg.chat.title || botMsg.chat.first_name || botMsg.chat.id 82 | } `, 83 | ); 84 | } 85 | userCouple = { 86 | botChatId: botMsg.chat.id, 87 | botUserId: botUser.id, 88 | userId: user.id.toJSNumber(), 89 | userChat: dialog.inputEntity, 90 | }; 91 | cachedUserCouples.push(userCouple); 92 | return dialog.inputEntity; 93 | } 94 | 95 | export async function getMessage( 96 | client: TelegramClient, 97 | inputPeer: Api.TypeInputPeer, 98 | botMsg: TelegramBot.Message, 99 | mediaId?: string, 100 | limit = 50, 101 | ): Promise { 102 | let messageCouple = cachedMessageCouples.find((msgCouple) => msgCouple.botMsgId == botMsg.message_id); 103 | if (messageCouple?.userMsg) return messageCouple.userMsg; 104 | 105 | const messagesRequests = cachedMessagesRequests.filter( 106 | (rq) => 107 | rq.botChatId == botMsg.chat.id && 108 | rq.msgDate <= botMsg.date && 109 | (rq.messages.last()?.date || botMsg.date - 1) >= botMsg.date, 110 | ); 111 | if (!messagesRequests.find((rq) => rq.limit == limit)) { 112 | // wait 1 sec for history updates in Telegram 113 | if (new Date().getTime() - unixTime2Date(botMsg.date, botMsg.message_id).getTime() < _1sec) 114 | await new Promise((resolve) => setTimeout(resolve, _1sec)); 115 | let messages = await client.getMessages(inputPeer, { limit, reverse: true, offsetDate: botMsg.date - 2 }); 116 | // remove bot messages (fromId != undefined) 117 | messages = messages.filter((m) => m.fromId || m.peerId instanceof Api.PeerChannel) || []; 118 | messagesRequests.push({ botChatId: botMsg.chat.id, msgDate: botMsg.date, messages, limit }); 119 | cachedMessagesRequests.push(messagesRequests[0]); 120 | } 121 | 122 | if (!botMsg.text && !mediaId) { 123 | const { fileObject } = getFileObject(botMsg); 124 | const fileObjectToUse = fileObject instanceof Array ? fileObject.pop() : fileObject; 125 | mediaId = extractMediaId(fileObjectToUse.file_id); 126 | } 127 | const skipMsgIds = cachedMessageCouples 128 | .filter((msgCouple) => Math.abs(botMsg.date - msgCouple.date) <= 1 && msgCouple.botMsgId != botMsg.message_id) 129 | .map((msgCouple) => msgCouple.userMsg && msgCouple.userMsg.id); 130 | 131 | const messages = messagesRequests.map((rq) => rq.messages).reduce((accumulator, msgs) => accumulator.concat(msgs)); 132 | const unprocessedMessages = messages.filter((msg) => !skipMsgIds.contains(msg.id)); 133 | // add dateOffset, because different date rounding between bot api and user api for unknown reason 134 | const userMsg = findUserMsg(unprocessedMessages, botMsg, mediaId); 135 | 136 | if (!userMsg && limit < 200 && messages.length > 0 && botMsg.date + 1 >= (messages.last()?.date || botMsg.date + 1)) 137 | return await getMessage(client, inputPeer, botMsg, mediaId, limit + 150); 138 | 139 | if (!userMsg) { 140 | throw new Error(cantFindTheMessage); 141 | } 142 | if (cachedMessageCouples.find((mc) => mc.userMsg.id == userMsg.id)) { 143 | throw new Error( 144 | "Because there may be several identical messages, it is not possible to pinpoint which message is needed.", 145 | ); 146 | } 147 | messageCouple = { 148 | date: botMsg.date, 149 | creationTime: new Date(), 150 | botMsgId: botMsg.message_id, 151 | userMsg: userMsg, 152 | }; 153 | cachedMessageCouples.push(messageCouple); 154 | return userMsg; 155 | } 156 | 157 | function findUserMsgByOffset( 158 | messages: Api.Message[], 159 | botMsg: TelegramBot.Message, 160 | // add dateOffset, because different date rounding between bot api and user api for unknown reason 161 | dateOffset = 0, 162 | mediaId?: string, 163 | ): Api.Message | undefined { 164 | return messages.find((m) => { 165 | const equalDates = m.date + dateOffset == botMsg.date; 166 | if (!equalDates) return false; 167 | //if msg was edited then it's enough only equal dates 168 | if (m.editDate) return true; 169 | const equalTexts = (m.message || "") == (botMsg.text || botMsg.caption || ""); 170 | if (!equalTexts) return false; 171 | const equalGroup = (m.groupedId?.toJSNumber() || 0) == (botMsg.media_group_id || 0); 172 | if (!equalGroup) return false; 173 | const equalMedia = !(mediaId && m.media) || mediaId == getMediaId(m.media)?.toString(); 174 | return equalMedia; 175 | }); 176 | } 177 | 178 | export function findUserMsg(messages: Api.Message[], botMsg: TelegramBot.Message, mediaId?: string) { 179 | return ( 180 | findUserMsgByOffset(messages, botMsg, 0, mediaId) || 181 | findUserMsgByOffset(messages, botMsg, 1, mediaId) || 182 | findUserMsgByOffset(messages, botMsg, -1, mediaId) 183 | ); 184 | } 185 | -------------------------------------------------------------------------------- /src/telegram/convertors/clientMessageToBotMessage.ts: -------------------------------------------------------------------------------- 1 | import TelegramBot, { Message, MessageEntity, MessageEntityType } from "node-telegram-bot-api"; // Import the Message interface from node-telegram-bot-api 2 | import { Api } from "telegram"; 3 | import { Entity } from "telegram/define"; 4 | 5 | function getChatType(entity: Entity | undefined): TelegramBot.ChatType { 6 | return entity instanceof Api.User 7 | ? "private" 8 | : entity instanceof Api.Chat 9 | ? "supergroup" 10 | : entity instanceof Api.Channel 11 | ? "channel" 12 | : "group"; 13 | } 14 | 15 | export function getUser(entity: Entity): TelegramBot.User | undefined { 16 | // Api.User | Api.Chat | Api.Channel 17 | if (entity instanceof Api.User) 18 | return { 19 | id: entity.id.toJSNumber(), 20 | username: entity.username, 21 | first_name: entity.firstName || entity.id.toString(), 22 | last_name: entity.lastName, 23 | is_bot: entity.bot || false, 24 | language_code: entity.langCode, 25 | }; 26 | else if (entity instanceof Api.Chat) 27 | return { 28 | id: entity.id.toJSNumber(), 29 | username: undefined, 30 | first_name: entity.title, 31 | is_bot: false, 32 | }; 33 | else if (entity instanceof Api.Channel) 34 | return { 35 | id: entity.id.toJSNumber(), 36 | username: entity.username, 37 | first_name: entity.title, 38 | is_bot: false, 39 | }; 40 | else return undefined; 41 | } 42 | 43 | export function getChat(entity: Entity): TelegramBot.Chat | undefined { 44 | // Api.User | Api.Chat | Api.Channel 45 | if (entity instanceof Api.User) 46 | return { 47 | id: entity.id.toJSNumber(), 48 | username: entity.username, 49 | title: entity.firstName + " " + entity.lastName, 50 | first_name: entity.firstName, 51 | last_name: entity.lastName, 52 | type: getChatType(entity), 53 | }; 54 | else if (entity instanceof Api.Chat) 55 | return { 56 | id: entity.id.toJSNumber(), 57 | username: undefined, 58 | title: entity.title, 59 | type: getChatType(entity), 60 | }; 61 | else if (entity instanceof Api.Channel) 62 | return { 63 | id: entity.id.toJSNumber(), 64 | username: entity.username, 65 | title: entity.title, 66 | type: getChatType(entity), 67 | }; 68 | else return undefined; 69 | } 70 | 71 | // Mapping function 72 | export function convertClientMsgToBotMsg(clientMsg: Api.Message): Message { 73 | const botChatType: TelegramBot.ChatType = getChatType(clientMsg.chat); 74 | const botChat: TelegramBot.Chat = { id: clientMsg.chatId?.toJSNumber() || 0, type: botChatType }; 75 | const botMsg: Message = { chat: botChat, date: clientMsg.date, message_id: clientMsg.id }; 76 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 77 | (botMsg as any).clientId = clientMsg.id; 78 | 79 | // Map similar fields 80 | // _TODO text must be undefined if message with file 81 | botMsg.text = clientMsg.message; 82 | botMsg.caption = clientMsg.text; 83 | // _TODO convert reply_markup 84 | //botMsg.reply_markup = clientMsg.replyMarkup; 85 | // Converting entities 86 | // _TODO caption_entities if message with file 87 | if (clientMsg.entities) { 88 | botMsg.entities = clientMsg.entities.map((entity) => { 89 | const entityType: MessageEntityType = 90 | entity instanceof Api.MessageEntityBold 91 | ? "bold" 92 | : entity instanceof Api.MessageEntityItalic 93 | ? "italic" 94 | : entity instanceof Api.MessageEntityCode 95 | ? "code" 96 | : entity instanceof Api.MessageEntityPre 97 | ? "pre" 98 | : entity instanceof Api.MessageEntityTextUrl 99 | ? "text_link" 100 | : entity instanceof Api.MessageEntityUnderline 101 | ? "underline" 102 | : "bold"; 103 | const messageEntity: MessageEntity = { 104 | type: entityType, 105 | offset: entity.offset, 106 | length: entity.length, 107 | }; 108 | return messageEntity; 109 | }); 110 | } 111 | 112 | // For forum-related fields, these might not be directly mappable. 113 | // Placeholder, may require more API calls 114 | botMsg.forum_topic_created = undefined; 115 | botMsg.forum_topic_edited = undefined; 116 | 117 | // More complex mappings 118 | // _TODO finish convert of all missing fields 119 | // botMsg.from = clientMsg.fromId ? { id: parseInt(clientMsg.fromId.toString(), 10) } : undefined; 120 | // botMsg.document = clientMsg.document ? { file_id: clientMsg.document.id.toString() } : undefined; 121 | // botMsg.reply_to_message = clientMsg.replyTo ? convertClientMsgToBotMsg(clientMsg.replyTo) : undefined; 122 | botMsg.forward_date = clientMsg.date; // Assuming the date is the forward_date in gramJS 123 | 124 | // Forwarding fields, placeholders, might require more API calls 125 | botMsg.forward_from = undefined; 126 | botMsg.forward_from_chat = undefined; 127 | botMsg.forward_from_message_id = undefined; 128 | botMsg.forward_sender_name = clientMsg.postAuthor; 129 | botMsg.forward_signature = undefined; 130 | botMsg.media_group_id = clientMsg.groupedId?.toString(); 131 | botMsg.message_thread_id = undefined; // Not directly mappable 132 | 133 | return botMsg; 134 | } 135 | -------------------------------------------------------------------------------- /src/telegram/user/client.ts: -------------------------------------------------------------------------------- 1 | import { Api, TelegramClient } from "telegram"; 2 | import { StoreSession } from "telegram/sessions"; 3 | import { releaseVersion, versionALessThanVersionB } from "release-notes.mjs"; 4 | import TelegramBot from "node-telegram-bot-api"; 5 | import QRCode from "qrcode"; 6 | import os from "os"; 7 | import { convertBotFileToMessageMedia } from "../convertors/botFileToMessageMedia"; 8 | import { ProgressBarType, _3MB, createProgressBar, deleteProgressBar, updateProgressBar } from "../bot/progressBar"; 9 | import { getInputPeer, getMessage } from "../convertors/botMessageToClientMessage"; 10 | import { formatDateTime } from "src/utils/dateUtils"; 11 | import { LogLevel, Logger } from "telegram/extensions/Logger"; 12 | import { _1min, _5sec } from "src/utils/logUtils"; 13 | import * as config from "./config"; 14 | import bigInt from "big-integer"; 15 | import { PromisedWebSockets } from "telegram/extensions"; 16 | 17 | export type SessionType = "bot" | "user"; 18 | 19 | let client: TelegramClient | undefined; 20 | let _botToken: string | undefined; 21 | let _sessionType: SessionType; 22 | let _sessionId: number; 23 | export let clientUser: Api.User | undefined; 24 | let _voiceTranscripts: Map | undefined; 25 | let lastReconnectTime = new Date(); 26 | 27 | const NotConnected = new Error("Can't connect to the Telegram Api"); 28 | const NotAuthorized = new Error("Not authorized"); 29 | const NotAuthorizedAsUser = new Error("Not authorized as user. You have to connect as user"); 30 | 31 | export function getNewSessionId(): number { 32 | return Number(formatDateTime(new Date(), "YYYYMMDDHHmmssSSS")); 33 | } 34 | 35 | export const insiderChannel = new Api.PeerChannel({ channelId: bigInt("1913400014") }); 36 | 37 | // Stop the bot polling 38 | export async function stop() { 39 | try { 40 | if (client) { 41 | client.setLogLevel(LogLevel.NONE); 42 | await client.destroy(); 43 | } 44 | } catch { 45 | /* empty */ 46 | } finally { 47 | client = undefined; 48 | _botToken = undefined; 49 | _voiceTranscripts = undefined; 50 | } 51 | } 52 | 53 | // init and connect to Telegram Api 54 | export async function init(sessionId: number, sessionType: SessionType, deviceId: string) { 55 | if (!client || _sessionType !== sessionType || _sessionId !== sessionId) { 56 | await stop(); 57 | const logger = new Logger(LogLevel.ERROR); 58 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 59 | logger.log = (level, message, color) => { 60 | console.log(`Telegram Sync => User connection error: ${message}`); 61 | // TODO in 2024: add user connection status checking and setting by controlling error and info logs 62 | //if (message == "Automatic reconnection failed 2 time(s)") 63 | }; 64 | const session = new StoreSession(`${sessionType}_${sessionId}_${deviceId}`); 65 | _sessionId = sessionId; 66 | _sessionType = sessionType; 67 | client = new TelegramClient(session, config.dIipa, config.hsaHipa, { 68 | connectionRetries: 10, 69 | deviceModel: os.hostname() || os.type(), 70 | appVersion: releaseVersion, 71 | useWSS: true, 72 | networkSocket: PromisedWebSockets, 73 | baseLogger: logger, 74 | }); 75 | } 76 | 77 | if (!client) throw NotConnected; 78 | if (!client.connected) { 79 | try { 80 | await client.connect(); 81 | const authorized = await client.checkAuthorization(); 82 | if (sessionType == "user" && authorized && (await client.isBot())) 83 | throw new Error("Stored session conflict. Try to log in again."); 84 | if (!authorized) clientUser = undefined; 85 | else if (!clientUser && authorized) clientUser = (await client.getMe()) as Api.User; 86 | } catch (e) { 87 | if ( 88 | sessionType == "user" && 89 | !(e instanceof Error && e.message.includes("Could not find a matching Constructor ID")) 90 | ) { 91 | await init(_sessionId, "bot", deviceId); 92 | throw new Error(`Login as user failed. Error: ${e}`); 93 | } else throw e; 94 | } 95 | } 96 | } 97 | 98 | export async function reconnect(checkInterval = true): Promise { 99 | if (!client) return false; 100 | if (!client.connected && (!checkInterval || new Date().getTime() - lastReconnectTime.getTime() >= _1min)) { 101 | lastReconnectTime = new Date(); 102 | await client.connect(); 103 | } 104 | return client.connected || false; 105 | } 106 | 107 | export async function isAuthorizedAsUser(): Promise { 108 | return (client && (await client.checkAuthorization()) && !(await client.isBot())) || false; 109 | } 110 | 111 | export async function signInAsBot(botToken: string) { 112 | if (!client) throw NotConnected; 113 | if (await client.checkAuthorization()) { 114 | if (!(await client.isBot())) throw new Error("Bot session is missed"); 115 | if (!_botToken) _botToken = botToken; 116 | if (_botToken == botToken) return; 117 | } 118 | await client 119 | .signInBot( 120 | { 121 | apiId: config.dIipa, 122 | apiHash: config.hsaHipa, 123 | }, 124 | { 125 | botAuthToken: botToken, 126 | }, 127 | ) 128 | .then(async (botUser) => { 129 | _botToken = botToken; 130 | clientUser = botUser as Api.User; 131 | return botUser; 132 | }) 133 | .catch((e) => { 134 | _botToken = undefined; 135 | clientUser = undefined; 136 | throw new Error(e); 137 | }); 138 | } 139 | 140 | export async function signInAsUserWithQrCode(container: HTMLDivElement, password?: string) { 141 | if (!client) throw NotConnected; 142 | if ((await client.checkAuthorization()) && (await client.isBot())) 143 | throw new Error("User session is missed. Try to restart the plugin or Obsidian"); 144 | await client 145 | .signInUserWithQrCode( 146 | { apiId: config.dIipa, apiHash: config.hsaHipa }, 147 | { 148 | qrCode: async (qrCode) => { 149 | const url = "tg://login?token=" + qrCode.token.toString("base64"); 150 | const qrCodeSvg = await QRCode.toString(url, { type: "svg" }); 151 | const parser = new DOMParser(); 152 | const svg = parser.parseFromString(qrCodeSvg, "image/svg+xml").documentElement; 153 | svg.setAttribute("width", "150"); 154 | svg.setAttribute("height", "150"); 155 | // Removes all children from `container` 156 | while (container.firstChild) { 157 | container.removeChild(container.firstChild); 158 | } 159 | container.appendChild(svg); 160 | }, 161 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 162 | password: async (hint) => { 163 | return password ? password : ""; 164 | }, 165 | onError: async (error) => { 166 | container.setText(`Error: ${error.message}`); 167 | console.log(`Telegram Sync => ${error}`); 168 | return true; 169 | }, 170 | }, 171 | ) 172 | .then((user) => { 173 | clientUser = user as Api.User; 174 | return clientUser; 175 | }) 176 | .catch((e) => { 177 | clientUser = undefined; 178 | console.log(`Telegram Sync => ${e}`); 179 | }); 180 | } 181 | 182 | async function checkBotService(): Promise { 183 | if (!client || !(await reconnect())) throw NotConnected; 184 | if (!(await client.checkAuthorization())) throw NotAuthorized; 185 | return client; 186 | } 187 | 188 | export async function checkUserService(): Promise<{ checkedClient: TelegramClient; checkedUser: Api.User }> { 189 | const checkedClient = await checkBotService(); 190 | if ((await checkedClient.isBot()) || !clientUser) throw NotAuthorizedAsUser; 191 | return { checkedClient, checkedUser: clientUser }; 192 | } 193 | 194 | // download files > 20MB 195 | export async function downloadMedia( 196 | bot: TelegramBot, 197 | botMsg: TelegramBot.Message, 198 | fileId: string, 199 | fileSize: number, 200 | botUser?: TelegramBot.User, 201 | ) { 202 | const checkedClient = await checkBotService(); 203 | 204 | // user clients needs different file id 205 | let stage = 0; 206 | let message: Api.Message | undefined = undefined; 207 | if (clientUser && botUser && (await isAuthorizedAsUser())) { 208 | const inputPeer = await getInputPeer(checkedClient, clientUser, botUser, botMsg); 209 | message = await getMessage(checkedClient, inputPeer, botMsg); 210 | } 211 | 212 | const progressBarMessage = 213 | fileSize > _3MB ? await createProgressBar(bot, botMsg, ProgressBarType.DOWNLOADING) : undefined; 214 | return await checkedClient 215 | .downloadMedia(message || convertBotFileToMessageMedia(fileId || "", fileSize), { 216 | progressCallback: async (receivedBytes, totalBytes) => { 217 | stage = await updateProgressBar( 218 | bot, 219 | botMsg, 220 | progressBarMessage, 221 | totalBytes.toJSNumber() || fileSize, 222 | receivedBytes.toJSNumber(), 223 | stage, 224 | ); 225 | }, 226 | }) 227 | .then(async (data) => { 228 | return data; 229 | }) 230 | .finally(async () => { 231 | await deleteProgressBar(bot, botMsg, progressBarMessage); 232 | }); 233 | } 234 | 235 | export async function sendReaction(botUser: TelegramBot.User, botMsg: TelegramBot.Message, emoticon: string) { 236 | const { checkedClient, checkedUser } = await checkUserService(); 237 | const inputPeer = await getInputPeer(checkedClient, checkedUser, botUser, botMsg); 238 | const message = await getMessage(checkedClient, inputPeer, botMsg); 239 | await checkedClient.invoke( 240 | new Api.messages.SendReaction({ 241 | peer: inputPeer, 242 | msgId: message.id, 243 | reaction: [new Api.ReactionEmoji({ emoticon })], 244 | }), 245 | ); 246 | } 247 | 248 | export async function transcribeAudio( 249 | bot: TelegramBot, 250 | botMsg: TelegramBot.Message, 251 | botUser?: TelegramBot.User, 252 | mediaId?: string, 253 | limit = 15, // minutes for waiting transcribing (not for the audio) 254 | ): Promise { 255 | if (botMsg.text || !(botMsg.voice || botMsg.video_note)) { 256 | return ""; 257 | } 258 | if (!_voiceTranscripts) _voiceTranscripts = new Map(); 259 | if (_voiceTranscripts.size > 100) _voiceTranscripts.clear(); 260 | if (_voiceTranscripts.has(`${botMsg.chat.id}_${botMsg.message_id}`)) 261 | return _voiceTranscripts.get(`${botMsg.chat.id}_${botMsg.message_id}`) || ""; 262 | 263 | const { checkedClient, checkedUser } = await checkUserService(); 264 | if (!checkedUser.premium) { 265 | throw new Error( 266 | "Transcribing voices available only for Telegram Premium subscribers! Remove {{voiceTranscript}} from current template or login with a premium user.", 267 | ); 268 | } 269 | if (!botUser) return ""; 270 | const inputPeer = await getInputPeer(checkedClient, checkedUser, botUser, botMsg); 271 | const message = await getMessage(checkedClient, inputPeer, botMsg, mediaId); 272 | let transcribedAudio: Api.messages.TranscribedAudio | undefined; 273 | 274 | let stage = 0; 275 | const progressBarMessage = await createProgressBar(bot, botMsg, ProgressBarType.TRANSCRIBING); 276 | try { 277 | // to avoid endless loop, limited waiting 278 | for (let i = 1; i <= limit * 14; i++) { 279 | transcribedAudio = await checkedClient.invoke( 280 | new Api.messages.TranscribeAudio({ 281 | peer: inputPeer, 282 | msgId: message.id, 283 | }), 284 | ); 285 | stage = await updateProgressBar(bot, botMsg, progressBarMessage, 14, i, stage); 286 | if (transcribedAudio.pending) 287 | await new Promise((resolve) => setTimeout(resolve, _5sec)); // 5 sec delay between updates 288 | else if (i == limit * 14) 289 | throw new Error("Very long audio. Transcribing can't be longer then 15 min lasting."); 290 | else break; 291 | } 292 | } finally { 293 | await deleteProgressBar(bot, botMsg, progressBarMessage); 294 | } 295 | if (!transcribedAudio) throw new Error("Can't transcribe the audio"); 296 | if (!_voiceTranscripts.has(`${botMsg.chat.id}_${botMsg.message_id}`)) 297 | _voiceTranscripts.set(`${botMsg.chat.id}_${botMsg.message_id}`, transcribedAudio.text); 298 | return transcribedAudio.text; 299 | } 300 | 301 | export async function subscribedOnInsiderChannel(): Promise { 302 | if (!client || !client.connected || _sessionType == "bot") return false; 303 | try { 304 | const { checkedClient } = await checkUserService(); 305 | const messages = await checkedClient.getMessages(insiderChannel, { limit: 1 }); 306 | return messages.length > 0; 307 | } catch (e) { 308 | return false; 309 | } 310 | } 311 | 312 | export async function getLastBetaRelease(currentVersion: string): Promise<{ betaVersion: string; mainJs: Buffer }> { 313 | const { checkedClient } = await checkUserService(); 314 | const messages = await checkedClient.getMessages(insiderChannel, { 315 | limit: 10, 316 | filter: new Api.InputMessagesFilterDocument(), 317 | search: "-beta.", 318 | }); 319 | if (messages.length == 0) throw new Error("No beta versions in Insider channel!"); 320 | const message = messages[0]; 321 | const match = message.message.match(/Obsidian Telegram Sync (\S+)/); 322 | const betaVersion = match ? match[1] : ""; 323 | if (!betaVersion) throw new Error("Can't find the version label in the message: " + message.message); 324 | if (versionALessThanVersionB(betaVersion, currentVersion)) 325 | throw new Error( 326 | `The last beta version ${betaVersion} can't be installed because it less than current version ${currentVersion}!`, 327 | ); 328 | const mainJs = (await messages[0].downloadMedia()) as Buffer; 329 | if (!mainJs) throw new Error("Can't find main.js in the last 10 messages of Insider channel"); 330 | return { betaVersion: betaVersion, mainJs }; 331 | } 332 | -------------------------------------------------------------------------------- /src/telegram/user/config.ts: -------------------------------------------------------------------------------- 1 | import { base64ToString } from "src/utils/fsUtils"; 2 | 3 | const id1 = "MjgzNw=="; 4 | const id2 = "NTY3NA=="; 5 | const id3 = "MmJhZjAxODY2MGY2OWFk"; 6 | const id4 = "MzMzYzVmNDUxNTRjNjM1YmQ="; 7 | 8 | export const dIipa = Number(base64ToString(id1) + base64ToString(id2)); 9 | export const hsaHipa = base64ToString(id3) + base64ToString(id4); 10 | 11 | export const emoticonProcessed = "👾"; 12 | export const emoticonProcessedEdited = "🦄"; 13 | -------------------------------------------------------------------------------- /src/telegram/user/sync.ts: -------------------------------------------------------------------------------- 1 | import { Api } from "telegram"; 2 | import { checkUserService, clientUser, subscribedOnInsiderChannel } from "./client"; 3 | import { getOffsetDate } from "src/utils/dateUtils"; 4 | import TelegramSyncPlugin from "src/main"; 5 | import { Dialog } from "telegram/tl/custom/dialog"; 6 | import { _5sec, cleanErrorCache, displayAndLog, displayAndLogError, _day, errorCache } from "src/utils/logUtils"; 7 | import { Notice } from "obsidian"; 8 | import TelegramBot from "node-telegram-bot-api"; 9 | import { extractMediaId } from "../convertors/botFileToMessageMedia"; 10 | import { getFileObject } from "../bot/message/getters"; 11 | import { findUserMsg } from "../convertors/botMessageToClientMessage"; 12 | import bigInt from "big-integer"; 13 | import { getChat, getUser } from "../convertors/clientMessageToBotMessage"; 14 | import { emoticonProcessed, emoticonProcessedEdited } from "./config"; 15 | 16 | const defaultDaysLimit = 14; 17 | const defaultDialogsLimit = 100; 18 | const defaultMessagesLimit = 1000; 19 | const _24hours = 24 * 60 * 60; 20 | 21 | interface ForwardedMessage { 22 | original: Api.Message; 23 | forwarded: Api.Message; 24 | } 25 | 26 | interface ChatForSearch { 27 | name: string; 28 | peer: Api.TypeInputPeer; 29 | } 30 | 31 | export interface ProcessOldMessagesSettings { 32 | lastProcessingDate: number; 33 | daysLimit: number; 34 | dialogsLimit: number; 35 | messagesLimit: number; 36 | chatsForSearch: ChatForSearch[]; 37 | } 38 | 39 | export let cachedUnprocessedMessages: ForwardedMessage[] = []; 40 | export let canUpdateProcessingDate = true; 41 | 42 | export function getDefaultProcessOldMessagesSettings(): ProcessOldMessagesSettings { 43 | return { 44 | lastProcessingDate: getOffsetDate(), 45 | daysLimit: defaultDaysLimit, 46 | dialogsLimit: defaultDialogsLimit, 47 | messagesLimit: defaultMessagesLimit, 48 | chatsForSearch: [], 49 | }; 50 | } 51 | 52 | export async function getChatsForSearch(plugin: TelegramSyncPlugin, offsetDays: number): Promise { 53 | let progress = "\n\n..."; 54 | let notification = `1 of 3\nSearching for chats with activity in the last "${offsetDays}" days`; 55 | const notice = new Notice(notification + progress, _day); 56 | const { checkedClient } = await checkUserService(); 57 | const botUserName = plugin.botUser?.username; 58 | if (!botUserName) { 59 | notice.setMessage("Can't execute searching for chats because can't identify bot username"); 60 | return []; 61 | } 62 | let allDialogs: Dialog[] = []; 63 | try { 64 | allDialogs = await checkedClient.getDialogs({ limit: plugin.settings.processOldMessagesSettings.dialogsLimit }); 65 | } catch (e) { 66 | notice.setMessage(`Search for "${botUserName}" in chats cancelled!\nError: ${JSON.stringify(e)}`); 67 | } 68 | allDialogs = allDialogs.filter( 69 | (dialog) => 70 | (!dialog.isUser || dialog.id?.toJSNumber() == plugin.botUser?.id) && 71 | dialog.date > getOffsetDate(offsetDays), 72 | ); 73 | notification = `${notification}\n\n2 of 3\nFiltering chats by bot "${botUserName}"`; 74 | const chatsForSearch: ChatForSearch[] = []; 75 | for (const dialog of allDialogs) { 76 | notice.setMessage(notification + progress); 77 | progress = progress + "."; 78 | const peer = dialog.inputEntity; 79 | const dialogName = dialog.title || dialog.name || dialog.id?.toString() || peer.toString(); 80 | if (dialog.isUser) { 81 | chatsForSearch.push({ name: dialogName, peer }); 82 | continue; 83 | } 84 | let participants: Api.User[] = []; 85 | try { 86 | participants = await checkedClient.getParticipants(dialog.dialog.peer, { 87 | filter: new Api.ChannelParticipantsBots(), 88 | search: botUserName, 89 | }); 90 | } catch (e) { 91 | if (e instanceof Error && e.message.contains("CHAT_ADMIN_REQUIRED")) continue; 92 | notice.setMessage( 93 | `Search for ${botUserName} in ${dialogName} participants cancelled!\nError: ${JSON.stringify(e)}`, 94 | ); 95 | } 96 | if (participants.length > 0) chatsForSearch.push({ name: dialogName, peer }); 97 | } 98 | notice.hide(); 99 | new Notice(`${notification}\n\n3 of 3\nDone`, _5sec); 100 | return chatsForSearch; 101 | } 102 | 103 | export async function getUnprocessedMessages(plugin: TelegramSyncPlugin): Promise { 104 | const botId = plugin.botUser?.id; 105 | if (!botId) { 106 | displayAndLogError( 107 | plugin, 108 | new Error("Can't execute searching for old unprocessed messages because can't identify bot id"), 109 | ); 110 | return []; 111 | } 112 | const { checkedClient } = await checkUserService(); 113 | const oneDayAgo = getOffsetDate(1); 114 | const processOldMessagesSettings = plugin.settings.processOldMessagesSettings; 115 | let offsetDate = getOffsetDate(processOldMessagesSettings.daysLimit); 116 | if (processOldMessagesSettings.lastProcessingDate > offsetDate) 117 | offsetDate = processOldMessagesSettings.lastProcessingDate; 118 | const unprocessedMessages: Api.Message[] = []; 119 | for (const chat of processOldMessagesSettings.chatsForSearch) { 120 | let messages: Api.Message[] = []; 121 | try { 122 | const peer = correctPeerObject(chat.peer); 123 | messages = await checkedClient.getMessages(peer, { 124 | limit: processOldMessagesSettings.messagesLimit, 125 | reverse: true, 126 | scheduled: false, 127 | offsetDate, 128 | waitTime: 2, 129 | }); 130 | } catch (e) { 131 | displayAndLogError( 132 | plugin, 133 | e, 134 | `Search for old unprocessed messages in ${chat.name} cancelled!`, 135 | undefined, 136 | undefined, 137 | 0, 138 | true, 139 | ); 140 | } 141 | 142 | messages = messages.filter((msg) => { 143 | // filter messages available for bot if order is not important 144 | if (msg.date > oneDayAgo && plugin.settings.parallelMessageProcessing) return false; 145 | 146 | // skip sync bot replies 147 | if (msg.fromId && msg.fromId instanceof Api.PeerUser && msg.fromId.userId.toJSNumber() == botId) 148 | return false; 149 | 150 | if (!msg.reactions || msg.reactions.results.length == 0) return true; 151 | // skip already processed messages 152 | const reactions = [{ userId: "-", reaction: "-" }]; 153 | if (msg.reactions.canSeeList) 154 | msg.reactions.recentReactions?.forEach((r) => { 155 | if (!(r.peerId instanceof Api.PeerUser) || !(r.reaction instanceof Api.ReactionEmoji)) return; 156 | reactions.push({ userId: r.peerId.userId.toString(), reaction: r.reaction.emoticon }); 157 | }); 158 | else 159 | msg.reactions.results.forEach((r) => { 160 | if (!(r.reaction instanceof Api.ReactionEmoji)) return; 161 | reactions.push({ userId: "0", reaction: r.reaction.emoticon }); 162 | }); 163 | 164 | if ( 165 | reactions.find( 166 | (r) => 167 | [emoticonProcessed, emoticonProcessedEdited].includes(r.reaction) && 168 | ["0", botId.toString(), clientUser?.id.toString()].includes(r.userId), 169 | ) 170 | ) 171 | return false; 172 | 173 | return true; 174 | }); 175 | if (messages.find((msg) => msg.date <= oneDayAgo)) unprocessedMessages.push(...messages); 176 | } 177 | return unprocessedMessages; 178 | } 179 | 180 | export async function forwardUnprocessedMessages(plugin: TelegramSyncPlugin) { 181 | const processOldMessagesSettings = plugin.settings.processOldMessagesSettings; 182 | if (!(await subscribedOnInsiderChannel())) return; 183 | if (processOldMessagesSettings.chatsForSearch.length == 0) { 184 | displayAndLog(plugin, "Processing old messages is skipped because chats for search are not listed", 0); 185 | return; 186 | } 187 | const nowDate = getOffsetDate(); 188 | const lastProcessingDate = processOldMessagesSettings.lastProcessingDate; 189 | if (Math.abs(nowDate - lastProcessingDate) < _24hours) return; 190 | 191 | cleanErrorCache(); 192 | stopUpdatingProcessingDate(); 193 | const unprocessedMessages = await getUnprocessedMessages(plugin); 194 | clearCachedUnprocessedMessages(); 195 | try { 196 | for (const message of unprocessedMessages) { 197 | try { 198 | if (message.forward && message.forward.chatId && !message.forward._chat) 199 | message.forward._chat = await message.forward.getChat(); 200 | const forwardedMessage = getFirstMessage(await message.forwardTo(message.inputChat || message.peerId)); 201 | if (!errorCache) processOldMessagesSettings.lastProcessingDate = message.editDate || message.date; 202 | cachedUnprocessedMessages.push({ original: message, forwarded: forwardedMessage }); 203 | } catch (e) { 204 | displayAndLogError( 205 | plugin, 206 | e, 207 | `Forwarding message "${message.message.slice(0, 100)}..." cancelled!`, 208 | undefined, 209 | undefined, 210 | 0, 211 | true, 212 | ); 213 | } 214 | } 215 | 216 | if (!errorCache) { 217 | processOldMessagesSettings.lastProcessingDate = nowDate; 218 | canUpdateProcessingDate = true; 219 | } else if (plugin.bot) { 220 | const { checkedUser } = await checkUserService(); 221 | await plugin.bot.sendMessage( 222 | checkedUser.id.toJSNumber(), 223 | `❌ ERRORS DURING SEARCH OF OLD UNPROCESSED MESSAGES:\n${errorCache}`, 224 | ); 225 | cleanErrorCache(); 226 | } 227 | } finally { 228 | await plugin.saveSettings(); 229 | } 230 | } 231 | 232 | export function addOriginalUserMsg(botMsg: TelegramBot.Message) { 233 | if (cachedUnprocessedMessages.length == 0) return; 234 | 235 | let mediaId = ""; 236 | if (!botMsg.text) { 237 | const { fileObject } = getFileObject(botMsg); 238 | const fileObjectToUse = fileObject instanceof Array ? fileObject.pop() : fileObject; 239 | mediaId = extractMediaId(fileObjectToUse.file_id); 240 | } 241 | 242 | const originalMessages = cachedUnprocessedMessages.map((userMsg) => userMsg.original); 243 | 244 | const userMsg = findUserMsg(originalMessages, botMsg, mediaId); 245 | 246 | if (userMsg) { 247 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 248 | (botMsg as any).userMsg = userMsg; 249 | return; 250 | } 251 | 252 | const forwardedMessages = cachedUnprocessedMessages.map((userMsg) => userMsg.forwarded); 253 | 254 | const forwardedMsg = findUserMsg(forwardedMessages, botMsg, mediaId); 255 | 256 | if (!forwardedMsg) return; 257 | 258 | const unprocessedMessage = cachedUnprocessedMessages.find((msg) => msg.forwarded.id == forwardedMsg.id); 259 | 260 | if (!unprocessedMessage) return; 261 | const originalMsg = unprocessedMessage.original; 262 | try { 263 | botMsg.edit_date = originalMsg.editDate; 264 | botMsg.date = originalMsg.date; 265 | botMsg.from = originalMsg.sender ? getUser(originalMsg.sender) : undefined; 266 | botMsg.forward_date = originalMsg.fwdFrom?.date; 267 | botMsg.forward_from = originalMsg.forward?.sender ? getUser(originalMsg.forward.sender) : undefined; 268 | botMsg.forward_from_chat = originalMsg.forward?.chat ? getChat(originalMsg.forward.chat) : undefined; 269 | botMsg.forward_from_message_id = originalMsg.fwdFrom?.channelPost; 270 | botMsg.forward_sender_name = originalMsg.fwdFrom?.fromName; 271 | botMsg.forward_signature = originalMsg.fwdFrom?.postAuthor; 272 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 273 | (botMsg as any).originalUserMsg = originalMsg; 274 | } finally { 275 | cachedUnprocessedMessages.remove(unprocessedMessage); 276 | } 277 | } 278 | 279 | export function stopUpdatingProcessingDate() { 280 | canUpdateProcessingDate = false; 281 | } 282 | 283 | export function clearCachedUnprocessedMessages() { 284 | cachedUnprocessedMessages = []; 285 | } 286 | 287 | function correctPeerObject(peer: Api.TypeInputPeer): Api.TypeInputPeer { 288 | if (peer.className == "InputPeerChannel") 289 | return new Api.InputPeerChannel({ channelId: peer.channelId, accessHash: peer.accessHash }); 290 | else if (peer.className == "InputPeerChat") return new Api.InputPeerChat({ chatId: peer.chatId }); 291 | else if (peer.className == "InputPeerUser") 292 | return new Api.InputPeerUser({ userId: peer.userId, accessHash: peer.accessHash }); 293 | else return new Api.InputPeerUser({ userId: bigInt(0), accessHash: bigInt(0) }); 294 | } 295 | 296 | function getFirstMessage(messages: Api.Message[] | Api.Message[][] | undefined): Api.Message { 297 | if (!messages || messages.length == 0) throw new Error("Unknown forwarding error"); 298 | if (Array.isArray(messages[0])) { 299 | return messages[0][0]; 300 | } else { 301 | return messages[0]; 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /src/telegram/user/user.ts: -------------------------------------------------------------------------------- 1 | import TelegramSyncPlugin from "src/main"; 2 | import * as Client from "./client"; 3 | import { StatusMessages, displayAndLogError } from "src/utils/logUtils"; 4 | import { enqueue } from "src/utils/queues"; 5 | 6 | export async function connect( 7 | plugin: TelegramSyncPlugin, 8 | sessionType: Client.SessionType, 9 | sessionId?: number, 10 | qrCodeContainer?: HTMLDivElement, 11 | password?: string, 12 | ): Promise { 13 | if (plugin.checkingUserConnection) return; 14 | if (!(sessionType == "user" || plugin.settings.botToken !== "")) return; 15 | if (sessionType == "user" && !sessionId && !qrCodeContainer) return; 16 | 17 | plugin.checkingUserConnection = true; 18 | try { 19 | const newSessionId = sessionId || Client.getNewSessionId(); 20 | if (sessionType == "bot" && !sessionId) { 21 | plugin.settings.telegramSessionId = newSessionId; 22 | plugin.settings.telegramSessionType = sessionType; 23 | await plugin.saveSettings(); 24 | } 25 | 26 | await Client.init(newSessionId, sessionType, plugin.currentDeviceId); 27 | 28 | if (sessionType == "user" && qrCodeContainer) { 29 | await Client.signInAsUserWithQrCode(qrCodeContainer, password); 30 | } 31 | 32 | plugin.userConnected = await Client.isAuthorizedAsUser(); 33 | 34 | if (sessionType == "bot" || !plugin.userConnected) { 35 | await Client.signInAsBot(await enqueue(plugin, plugin.getBotToken)); 36 | } 37 | 38 | if (sessionType == "user" && !plugin.userConnected) { 39 | let qrError = qrCodeContainer?.getText(); 40 | qrError = qrError?.contains("Error") ? qrError : "See errors in console (CTRL + SHIFT + I)"; 41 | return `Connection failed.\n${qrError}`; 42 | } 43 | 44 | if (plugin.userConnected && !sessionId) { 45 | plugin.settings.telegramSessionId = newSessionId; 46 | plugin.settings.telegramSessionType = sessionType; 47 | await plugin.saveSettings(); 48 | } 49 | } catch (error) { 50 | if (!error.message.includes("API_ID_PUBLISHED_FLOOD")) { 51 | plugin.userConnected = false; 52 | await displayAndLogError(plugin, error, "", "", undefined, 0); 53 | return `Connection failed.\n${error.message}`; 54 | } 55 | } finally { 56 | plugin.checkingUserConnection = false; 57 | } 58 | } 59 | 60 | export async function reconnect(plugin: TelegramSyncPlugin, displayError = false) { 61 | if (plugin.checkingUserConnection) return; 62 | plugin.checkingUserConnection = true; 63 | try { 64 | await Client.reconnect(false); 65 | plugin.userConnected = await Client.isAuthorizedAsUser(); 66 | } catch (error) { 67 | plugin.userConnected = false; 68 | if (displayError && plugin.isBotConnected() && plugin.settings.telegramSessionType == "user") { 69 | await displayAndLogError( 70 | plugin, 71 | error, 72 | StatusMessages.USER_DISCONNECTED, 73 | "Try restore the connection manually by restarting Obsidian or by refresh button in the plugin settings!", 74 | ); 75 | } 76 | } finally { 77 | plugin.checkingUserConnection = false; 78 | } 79 | } 80 | 81 | // Stop connection as user 82 | export async function disconnect(plugin: TelegramSyncPlugin) { 83 | try { 84 | await Client.stop(); 85 | } finally { 86 | plugin.userConnected = false; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/utils/arrayUtils.ts: -------------------------------------------------------------------------------- 1 | export function arrayMove(arr: T[], fromIndex: number, toIndex: number): void { 2 | if (toIndex < 0 || toIndex === arr.length) { 3 | return; 4 | } 5 | [arr[fromIndex], arr[toIndex]] = [arr[toIndex], arr[fromIndex]]; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/crypto256.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { base64ToString } from "src/utils/fsUtils"; 3 | 4 | const id1 = "c29iZXJoYWNrZXI="; 5 | const id2 = "S2V5"; 6 | const id3 = "SVY="; 7 | 8 | const algorithm = "aes-256-cbc"; 9 | const defaultKey = base64ToString(id1) + base64ToString(id2); 10 | const defaultIV = base64ToString(id1) + base64ToString(id3); 11 | 12 | export function encrypt(text: string, key = defaultKey, iv = defaultIV): string { 13 | const cipher = crypto.createCipheriv( 14 | algorithm, 15 | Buffer.from(padOrTrim(key, 32), "utf8"), 16 | Buffer.from(padOrTrim(iv, 16), "utf8"), 17 | ); 18 | let encrypted = cipher.update(text, "utf8", "hex"); 19 | encrypted += cipher.final("hex"); 20 | return encrypted; 21 | } 22 | 23 | export function decrypt(encryptedText: string, key = defaultKey, iv = defaultIV): string { 24 | const decipher = crypto.createDecipheriv( 25 | algorithm, 26 | Buffer.from(padOrTrim(key, 32), "utf8"), 27 | Buffer.from(padOrTrim(iv, 16), "utf8"), 28 | ); 29 | let decrypted = decipher.update(encryptedText, "hex", "utf8"); 30 | decrypted += decipher.final("utf8"); 31 | return decrypted; 32 | } 33 | 34 | export function padOrTrim(input: string, length: number) { 35 | return input.length > length ? input.slice(0, length) : input.padEnd(length, "0"); 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/dateUtils.ts: -------------------------------------------------------------------------------- 1 | import { moment } from "obsidian"; 2 | 3 | export function formatDateTime(date: Date, format: string): string { 4 | return moment(date).format(format); 5 | } 6 | 7 | export function date2DateString(date: Date): string { 8 | return moment(date).format("YYYYMMDD"); 9 | } 10 | 11 | export function date2TimeString(date: Date): string { 12 | return moment(date).format("HHmmssSSS"); 13 | } 14 | 15 | export function unixTime2Date(unixTime: number, offset = 0): Date { 16 | return new Date(unixTime * 1000 + new Date().getMilliseconds() + (offset % 1000)); 17 | } 18 | 19 | export function date2UnixTime(date: Date): number { 20 | return Math.floor(date.getTime() / 1000); 21 | } 22 | 23 | export function getOffsetDate(offsetDays = 0, startDate = new Date()): number { 24 | startDate.setDate(startDate.getDate() - offsetDays); 25 | return date2UnixTime(startDate); 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/fsUtils.ts: -------------------------------------------------------------------------------- 1 | import { TFile, TFolder, Vault, normalizePath } from "obsidian"; 2 | import { date2DateString, date2TimeString } from "./dateUtils"; 3 | import path from "path"; 4 | 5 | export const defaultDelimiter = "\n\n***\n\n"; 6 | 7 | // Create a folder path if it does not exist 8 | export async function createFolderIfNotExist(vault: Vault, folderPath: string) { 9 | if (!vault || !folderPath) { 10 | return; 11 | } 12 | const folder = vault.getAbstractFileByPath(normalizePath(folderPath)); 13 | 14 | if (folder && folder instanceof TFolder) { 15 | return; 16 | } 17 | 18 | if (folder && folder instanceof TFile) { 19 | throw new URIError( 20 | `Folder "${folderPath}" can't be created because there is a file with the same name. Change the path or rename the file.`, 21 | ); 22 | } 23 | 24 | await vault.createFolder(folderPath).catch((error) => { 25 | if (error.message !== "Folder already exists.") { 26 | throw error; 27 | } 28 | }); 29 | } 30 | 31 | export function sanitizeFileName(fileName: string): string { 32 | const invalidCharacters = /[\\/:*?"<>|\n\r]/g; 33 | return fileName.replace(invalidCharacters, "_"); 34 | } 35 | 36 | export function sanitizeFilePath(filePath: string): string { 37 | const invalidCharacters = /[\\:*?"<>|\n\r]/g; 38 | return normalizePath(truncatePathComponents(filePath.replace(invalidCharacters, "_"))); 39 | } 40 | 41 | export async function getUniqueFilePath( 42 | vault: Vault, 43 | createdFilePaths: string[], 44 | initialFilePath: string, 45 | date: Date, 46 | fileExtension: string, 47 | ): Promise { 48 | let fileFolderPath = path.dirname(initialFilePath); 49 | if (fileFolderPath != ".") await createFolderIfNotExist(vault, fileFolderPath); 50 | else fileFolderPath = ""; 51 | 52 | let filePath = initialFilePath; 53 | if (!(vault.getAbstractFileByPath(filePath) instanceof TFile)) return filePath; 54 | 55 | const initialFileName = path.basename(filePath, "." + fileExtension); 56 | const dateString = date2DateString(date); 57 | let fileId = Number(date2TimeString(date)); 58 | const collectFileName = () => `${initialFileName} - ${dateString}${fileId}.${fileExtension}`; 59 | let fileName = collectFileName(); 60 | 61 | let previousFilePath = ""; 62 | while ( 63 | previousFilePath != filePath && 64 | (createdFilePaths.includes(filePath) || vault.getAbstractFileByPath(filePath) instanceof TFile) 65 | ) { 66 | previousFilePath = filePath; 67 | fileId += 1; 68 | fileName = collectFileName(); 69 | filePath = fileFolderPath ? `${fileFolderPath}/${fileName}` : fileName; 70 | } 71 | createdFilePaths.push(filePath); 72 | return filePath; 73 | } 74 | 75 | export async function appendContentToNote( 76 | vault: Vault, 77 | notePath: string, 78 | newContent: string, 79 | startLine = "", 80 | delimiter = defaultDelimiter, 81 | reversedOrder = false, 82 | ) { 83 | if (!notePath || !newContent.trim()) return; 84 | if (startLine == undefined) startLine = ""; 85 | 86 | const noteFile: TFile = vault.getAbstractFileByPath(notePath) as TFile; 87 | 88 | let currentContent = ""; 89 | if (noteFile) currentContent = await vault.read(noteFile); 90 | let index = reversedOrder ? 0 : currentContent.length; 91 | if (currentContent.length == 0 && !startLine) delimiter = ""; 92 | newContent = reversedOrder ? newContent + delimiter : delimiter + newContent; 93 | 94 | if (startLine) { 95 | const startLineIndex = currentContent.indexOf(startLine); 96 | if (startLineIndex > -1) index = reversedOrder ? startLineIndex : startLineIndex + startLine.length; 97 | else newContent = reversedOrder ? newContent + startLine : startLine + newContent; 98 | } 99 | 100 | const content = currentContent.slice(0, index) + newContent + currentContent.slice(index); 101 | if (!noteFile) await vault.create(notePath, content); 102 | else if (currentContent != content) await vault.modify(noteFile, content); 103 | } 104 | 105 | export function base64ToString(base64: string): string { 106 | return Buffer.from(base64, "base64").toString("utf-8"); 107 | } 108 | 109 | function truncatePathComponents(filePath: string, maxLength = 200): string { 110 | const parsedPath = path.parse(filePath); 111 | 112 | // Split the path into its components (folders, subfolders, etc.) 113 | const pathComponents = parsedPath.dir.split("/"); 114 | 115 | // Truncate each path component if it exceeds maxLength characters 116 | const truncatedComponents = pathComponents.map((component) => 117 | component.length > maxLength ? component.substring(0, maxLength) : component, 118 | ); 119 | 120 | // Truncate the file name if it exceeds maxLength characters 121 | const truncatedFileName = 122 | parsedPath.name.length > maxLength ? parsedPath.name.substring(0, maxLength) : parsedPath.name; 123 | 124 | // Reassemble the full path 125 | const truncatedPath = path.join(...truncatedComponents, truncatedFileName + parsedPath.ext); 126 | 127 | return truncatedPath; 128 | } 129 | 130 | export async function replaceMainJs(vault: Vault, mainJs: Buffer | "main-prod.js") { 131 | const mainJsPath = normalizePath(vault.configDir + "/plugins/telegram-sync/main.js"); 132 | const mainProdJsPath = normalizePath(vault.configDir + "/plugins/telegram-sync/main-prod.js"); 133 | if (mainJs instanceof Buffer) { 134 | await vault.adapter.writeBinary(mainProdJsPath, await vault.adapter.readBinary(mainJsPath)); 135 | await vault.adapter.writeBinary(mainJsPath, mainJs); 136 | } else { 137 | if (!(await vault.adapter.exists(mainProdJsPath))) return; 138 | await vault.adapter.writeBinary(mainJsPath, await vault.adapter.readBinary(mainProdJsPath)); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/utils/logUtils.ts: -------------------------------------------------------------------------------- 1 | import TelegramBot from "node-telegram-bot-api"; 2 | import { Notice } from "obsidian"; 3 | import TelegramSyncPlugin from "src/main"; 4 | import { stopUpdatingProcessingDate } from "src/telegram/user/sync"; 5 | 6 | export const _1sec = 1000; 7 | export const _2sec = 2 * _1sec; 8 | export const _5sec = 5 * _1sec; 9 | export const _15sec = 15 * _1sec; 10 | export const _1min = 60 * _1sec; 11 | export const _2min = 2 * _1min; 12 | export const _5min = 5 * _1min; 13 | export const _30min = 30 * _1min; 14 | export const _1h = 60 * _1min; 15 | export const _2h = 2 * _1h; 16 | export const _day = 24 * _1h; 17 | 18 | // TODO LOW: connect with ConnectionStatus 19 | export enum StatusMessages { 20 | BOT_CONNECTED = "Telegram bot is connected!", 21 | BOT_DISCONNECTED = "Telegram bot is disconnected!", 22 | USER_DISCONNECTED = "Telegram user is disconnected!", 23 | } 24 | 25 | export let errorCache = ""; 26 | 27 | interface PersistentNotice { 28 | notice: Notice; 29 | message: string; 30 | } 31 | let persistentNotices: PersistentNotice[] = []; 32 | 33 | // Show notification and log message into console. 34 | export function displayAndLog(plugin: TelegramSyncPlugin, message: string, timeout?: number) { 35 | console.log(`${plugin.manifest.name} => ${message}`); 36 | 37 | if (timeout == 0) return; 38 | const notice = new Notice(message, timeout || _day); 39 | 40 | const hideBotDisconnectedMessages = message.contains(StatusMessages.BOT_CONNECTED); 41 | persistentNotices = persistentNotices.filter((persistentNotice) => { 42 | const shouldHide = 43 | (hideBotDisconnectedMessages && persistentNotice.message.contains(StatusMessages.BOT_DISCONNECTED)) || 44 | persistentNotice.message == message; 45 | if (shouldHide) { 46 | persistentNotice.notice.hide(); 47 | } 48 | return !shouldHide; 49 | }); 50 | 51 | if (!timeout) persistentNotices.push({ notice, message }); 52 | } 53 | 54 | // Show error to console, telegram, display 55 | export async function displayAndLogError( 56 | plugin: TelegramSyncPlugin, 57 | error: Error, 58 | status?: string, 59 | action?: string, 60 | msg?: TelegramBot.Message, 61 | timeout?: number, // 0 - do not show in obsidian | undefined - never hide 62 | addToCache?: boolean, 63 | ) { 64 | let beautyError = `${error.name}: ${error.message.replace(/Error: /g, "")}\n${status || ""}\n${action || ""}`; 65 | beautyError = beautyError.trim(); 66 | displayAndLog(plugin, beautyError, timeout); 67 | if (error.stack) console.log(error.stack); 68 | if (msg) { 69 | await plugin.bot?.sendMessage(msg.chat.id, `...❌...\n\n${beautyError}`, { 70 | reply_to_message_id: msg.message_id, 71 | }); 72 | } 73 | if (addToCache) 74 | errorCache = `${errorCache || ""}\n\n${status || ""}\n${error.name}: ${error.message.replace(/Error: /g, "")}`; 75 | if (msg && plugin.settings.retryFailedMessagesProcessing) stopUpdatingProcessingDate(); 76 | } 77 | 78 | export function cleanErrorCache() { 79 | errorCache = ""; 80 | } 81 | 82 | // changing GramJs version can cause cache issues and wrong alerts, so it's cure for it 83 | export function hideMTProtoAlerts(plugin: TelegramSyncPlugin) { 84 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 85 | const originalAlert = window.alert as any; 86 | if (!originalAlert.__isOverridden) { 87 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 88 | window.alert = function (message?: any) { 89 | if (message.includes("Missing MTProto Entity")) { 90 | localStorage.removeItem("GramJs:apiCache"); 91 | plugin.settings.cacheCleanupAtStartup = true; 92 | plugin.saveSettings(); 93 | displayAndLog( 94 | plugin, 95 | "Telegram Sync got errors during cache cleanup from the previous plugin version.\n\nPlease close all instances of Obsidian and restart it. You may need to repeat it twice.\n\nApologize for the inconvenience", 96 | ); 97 | return; 98 | } 99 | originalAlert(message); 100 | }; 101 | originalAlert.__isOverridden = true; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/utils/queues.ts: -------------------------------------------------------------------------------- 1 | import TelegramSyncPlugin from "src/main"; 2 | 3 | type AsyncStaticFunction = (...args: A) => Promise; 4 | type AsyncInstanceFunction = ( 5 | this: C, 6 | ...args: A 7 | ) => Promise; 8 | const queues = new Map>(); 9 | 10 | export async function enqueue(fn: AsyncStaticFunction, ...args: A): Promise; 11 | export async function enqueue( 12 | context: C, 13 | fn: AsyncInstanceFunction, 14 | ...args: A 15 | ): Promise; 16 | export async function enqueue( 17 | contextOrFn: AsyncStaticFunction | C, 18 | fnOrArgs: AsyncInstanceFunction | A, 19 | ...rest: A 20 | ): Promise { 21 | let error: Error | undefined; 22 | let context: C | undefined; 23 | let fn: AsyncStaticFunction | AsyncInstanceFunction; 24 | 25 | if (typeof contextOrFn === "function") { 26 | fn = contextOrFn as AsyncStaticFunction; 27 | if (fnOrArgs) rest.unshift(fnOrArgs); 28 | } else { 29 | context = contextOrFn; 30 | fn = fnOrArgs as AsyncInstanceFunction; 31 | } 32 | const args = rest; 33 | 34 | const queueKey = 35 | context instanceof TelegramSyncPlugin && fn.name == context.restartTelegram.name 36 | ? context.initTelegram.name 37 | : fn.name; 38 | 39 | if (!queueKey) throw new Error("Function should have a name"); 40 | 41 | const queue = (queues.get(queueKey) || Promise.resolve()) 42 | .then(async () => 43 | context ? await fn.call(context, ...args) : await (fn as AsyncStaticFunction)(...args), 44 | ) 45 | .catch((e) => { 46 | error = e; 47 | }); 48 | 49 | queues.set(queueKey, queue); 50 | 51 | const result = await queue; 52 | if (error) throw error; 53 | return result; 54 | } 55 | 56 | export async function enqueueByCondition( 57 | condition: boolean, 58 | fn: AsyncStaticFunction, 59 | ...args: A 60 | ): Promise; 61 | 62 | export async function enqueueByCondition( 63 | condition: boolean, 64 | context: C, 65 | fn: AsyncInstanceFunction, 66 | ...args: A 67 | ): Promise; 68 | 69 | export async function enqueueByCondition( 70 | condition: boolean, 71 | contextOrFn: AsyncStaticFunction | C, 72 | fnOrArgs: AsyncInstanceFunction | A, 73 | ...rest: A 74 | ): Promise { 75 | if (condition) { 76 | if (typeof contextOrFn === "function") { 77 | if (fnOrArgs) rest.unshift(fnOrArgs); 78 | return enqueue(contextOrFn as AsyncStaticFunction, ...rest); 79 | } else { 80 | return enqueue(contextOrFn as C, fnOrArgs as AsyncInstanceFunction, ...rest); 81 | } 82 | } else { 83 | let context: C | undefined; 84 | let fn: AsyncStaticFunction | AsyncInstanceFunction; 85 | const args = rest; 86 | 87 | if (typeof contextOrFn === "function") { 88 | fn = contextOrFn as AsyncStaticFunction; 89 | if (fnOrArgs) args.unshift(fnOrArgs); 90 | } else { 91 | context = contextOrFn; 92 | fn = fnOrArgs as AsyncInstanceFunction; 93 | } 94 | 95 | return context ? fn.call(context, ...args) : (fn as AsyncStaticFunction)(...args); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .settings-donation-container { 2 | width: 80%; 3 | height: 50vh; 4 | margin: 0 auto; 5 | text-align: center; 6 | color: var(--text-normal); 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "baseUrl": ".", 5 | "allowSyntheticDefaultImports": true, 6 | "inlineSourceMap": true, 7 | "inlineSources": true, 8 | "module": "ESNext", 9 | "target": "ES6", 10 | "allowJs": true, 11 | "noImplicitAny": true, 12 | "isolatedModules": true, 13 | "moduleResolution": "node", 14 | "strictNullChecks": true, 15 | "importHelpers": true, 16 | "lib": ["DOM", "ES5", "ES6", "ES7"], 17 | "skipLibCheck": true, 18 | "noUnusedLocals": true 19 | }, 20 | "include": ["**/*.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | const compatibleObsidianVersions = Object.values(versions); 14 | const latestCompatibleObsidianVersion = compatibleObsidianVersions[compatibleObsidianVersions.length - 1]; 15 | if (minAppVersion !== versions[latestCompatibleObsidianVersion]) { 16 | versions[targetVersion] = minAppVersion; 17 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 18 | } 19 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "1.0.0" 3 | } --------------------------------------------------------------------------------