4
0
mirror of https://github.com/cwinfo/matterbridge.git synced 2025-06-27 06:29:24 +00:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Wim
f7e22983a5 Release 0.7.0 2016-11-12 22:43:24 +01:00
Wim
cac9fb838c Update documentation 2016-11-12 22:41:56 +01:00
5265 changed files with 40821 additions and 1495272 deletions

View File

@ -1,2 +0,0 @@
Dockerfile
tgs.Dockerfile

View File

@ -1,3 +0,0 @@
go:
comments:
disabled: true

View File

@ -1,36 +0,0 @@
<!-- This is a bug report template. By following the instructions below and
filling out the sections with your information, you will help the us to get all
the necessary data to fix your issue.
You can also preview your report before submitting it.
Text between <!-- and --> marks will be invisible in the report.
-->
<!-- If you have a configuration problem, please first try to create a basic configuration following the instructions on [the wiki](https://github.com/42wim/matterbridge/wiki/How-to-create-your-config) before filing an issue. -->
### Environment
<!-- run `matterbridge -version` -->
<!-- If you're having problems with mattermost also specify the mattermost version. -->
Version:
<!-- What operating system are you using ? (be as specific as possible) -->
Operating system:
<!-- If you compiled matterbridge yourself:
* Specify the output of `go version`
* Specify the output of `git rev-parse HEAD` -->
### Please describe the expected behavior.
### Please describe the actual behavior.
<!-- Use logs from running `matterbridge -debug` if possible. -->
### Any steps to reproduce the behavior?
### Please add your configuration file
<!-- (be sure to exclude or anonymize private data (tokens/passwords)) -->

View File

@ -1,27 +0,0 @@
---
name: Bug report
about: Create a report to help us improve. (Check the FAQ on the wiki first)
labels: bug
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots/debug logs**
If applicable, add screenshots to help explain your problem.
Use logs from running `matterbridge -debug` if possible.
**Environment (please complete the following information):**
- OS: [e.g. linux]
- Matterbridge version: output of `matterbridge -version`
- If self compiled: output of `git rev-parse HEAD`
**Additional context**
Please add your configuration file (be sure to exclude or anonymize private data (tokens/passwords))

View File

@ -1,18 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
labels: enhancement
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -1,22 +0,0 @@
# Docs: <https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/customizing-dependency-updates>
version: 2
updates:
- package-ecosystem: gomod
directory: /
schedule: {interval: weekly}
reviewers: [42wim]
assignees: [42wim]
- package-ecosystem: github-actions
directory: /
schedule: {interval: weekly}
reviewers: [42wim]
assignees: [42wim]
- package-ecosystem: docker
directory: /
schedule: {interval: weekly}
reviewers: [42wim]
assignees: [42wim]

View File

@ -1,71 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
name: "CodeQL"
on:
push:
branches: [master]
pull_request:
# The branches below must be a subset of the branches above
branches: [master]
schedule:
- cron: '0 16 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Override automatic language detection by changing the below list
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
language: ['go']
# Learn more...
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@ -1,58 +0,0 @@
name: Development
on: [push, pull_request]
jobs:
lint:
name: golangci-lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 20
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v2
with:
version: latest
args: "-v --new-from-rev HEAD~5"
test-build-upload:
strategy:
matrix:
go-version: [1.17.x]
platform: [ubuntu-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
stable: false
- name: Checkout code
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Test
run: go test ./... -mod=vendor
- name: Build
run: |
mkdir -p output/{win,lin,arm,mac}
VERSION=$(git describe --tags)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -X github.com/42wim/matterbridge/version.GitHash=$(git log --pretty=format:'%h' -n 1)" -o output/lin/matterbridge-$VERSION-linux-amd64
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -X github.com/42wim/matterbridge/version.GitHash=$(git log --pretty=format:'%h' -n 1)" -o output/win/matterbridge-$VERSION-windows-amd64.exe
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -X github.com/42wim/matterbridge/version.GitHash=$(git log --pretty=format:'%h' -n 1)" -o output/mac/matterbridge-$VERSION-darwin-amd64
- name: Upload linux 64-bit
if: startsWith(matrix.go-version,'1.17')
uses: actions/upload-artifact@v2
with:
name: matterbridge-linux-64bit
path: output/lin
- name: Upload windows 64-bit
if: startsWith(matrix.go-version,'1.17')
uses: actions/upload-artifact@v2
with:
name: matterbridge-windows-64bit
path: output/win
- name: Upload darwin 64-bit
if: startsWith(matrix.go-version,'1.17')
uses: actions/upload-artifact@v2
with:
name: matterbridge-darwin-64bit
path: output/mac

View File

@ -1,68 +0,0 @@
name: docker
on:
push:
branches:
- 'master'
tags:
- 'v*'
pull_request:
branches:
- 'master'
jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1
with:
platforms: amd64,arm64
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
-
name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: 42wim/matterbridge,ghcr.io/42wim/matterbridge
flavor: |
latest=true
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern=stable
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}}
-
name: Login to DockerHub
uses: docker/login-action@v1
if: github.event_name != 'pull_request'
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Log into registry ghcr.io
uses: docker/login-action@v1
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

9
.gitignore vendored
View File

@ -1,9 +0,0 @@
# Exclude matterbridge binary
/matterbridge
/matterbridge.exe
# Exclude configuration file
matterbridge.toml
# Exclude IDE Files
.vscode

View File

@ -1,236 +0,0 @@
# For full documentation of the configuration options please
# see: https://github.com/golangci/golangci-lint#config-file.
# options for analysis running
run:
# default concurrency is the available CPU number
# concurrency: 4
# timeout for analysis, e.g. 30s, 5m, default is 1m
deadline: 5m
# exit code when at least one issue was found, default is 1
issues-exit-code: 1
# include test files or not, default is true
tests: true
# list of build tags, all linters use it. Default is empty list.
build-tags:
# which dirs to skip: they won't be analyzed;
# can use regexp here: generated.*, regexp is applied on full path;
# default value is empty list, but next dirs are always skipped independently
# from this option's value:
# vendor$, third_party$, testdata$, examples$, Godeps$, builtin$
skip-dirs: gateway/bridgemap$
# which files to skip: they will be analyzed, but issues from them
# won't be reported. Default value is empty list, but there is
# no need to include all autogenerated files, we confidently recognize
# autogenerated files. If it's not please let us know.
skip-files:
# output configuration options
output:
# colored-line-number|line-number|json|tab|checkstyle, default is "colored-line-number"
format: colored-line-number
# print lines of code with issue, default is true
print-issued-lines: true
# print linter name in the end of issue text, default is true
print-linter-name: true
# all available settings of specific linters, we can set an option for
# a given linter even if we deactivate that same linter at runtime
linters-settings:
errcheck:
# report about not checking of errors in type assertions: `a := b.(MyStruct)`;
# default is false: such cases aren't reported by default.
check-type-assertions: false
# report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`;
# default is false: such cases aren't reported by default.
check-blank: false
govet:
# report about shadowed variables
check-shadowing: true
golint:
# minimal confidence for issues, default is 0.8
min-confidence: 0.8
gofmt:
# simplify code: gofmt with `-s` option, true by default
simplify: true
goimports:
# put imports beginning with prefix after 3rd-party packages;
# it's a comma-separated list of prefixes
local-prefixes: github.com
gocyclo:
# minimal code complexity to report, 30 by default (but we recommend 10-20)
min-complexity: 15
maligned:
# print struct with more effective memory layout or not, false by default
suggest-new: true
dupl:
# tokens count to trigger issue, 150 by default
threshold: 150
goconst:
# minimal length of string constant, 3 by default
min-len: 3
# minimal occurrences count to trigger, 3 by default
min-occurrences: 3
depguard:
list-type: blacklist
include-go-root: false
packages:
# List of packages that we would want to blacklist for... reasons.
misspell:
# Correct spellings using locale preferences for US or UK.
# Default is to use a neutral variety of English.
# Setting locale to US will correct the British spelling of 'colour' to 'color'.
lll:
# max line length, lines longer will be reported. Default is 120.
# '\t' is counted as 1 character by default, and can be changed with the tab-width option
line-length: 150
# tab width in spaces. Default to 1.
tab-width: 1
unused:
# treat code as a program (not a library) and report unused exported identifiers; default is false.
# XXX: if you enable this setting, unused will report a lot of false-positives in text editors:
# if it's called for subdir of a project it can't find funcs usages. All text editor integrations
# with golangci-lint call it on a directory with the changed file.
check-exported: false
unparam:
# Inspect exported functions, default is false. Set to true if no external program/library imports your code.
# XXX: if you enable this setting, unparam will report a lot of false-positives in text editors:
# if it's called for subdir of a project it can't find external interfaces. All text editor integrations
# with golangci-lint call it on a directory with the changed file.
check-exported: false
nakedret:
# make an issue if func has more lines of code than this setting and it has naked returns; default is 30
max-func-lines: 0 # Warn on all naked returns.
prealloc:
# XXX: we don't recommend using this linter before doing performance profiling.
# For most programs usage of prealloc will be a premature optimization.
# Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them.
# True by default.
simple: true
range-loops: true # Report preallocation suggestions on range loops, true by default
for-loops: false # Report preallocation suggestions on for loops, false by default
gocritic:
# which checks should be enabled; can't be combined with 'disabled-checks';
# default are: [appendAssign assignOp caseOrder dupArg dupBranchBody dupCase flagDeref
# ifElseChain regexpMust singleCaseSwitch sloppyLen switchTrue typeSwitchVar underef
# unlambda unslice rangeValCopy defaultCaseOrder];
# all checks list: https://github.com/go-critic/checkers
# disabled for now - hugeParam
enabled-checks:
- appendAssign
- assignOp
- boolExprSimplify
- builtinShadow
- captLocal
- caseOrder
- commentedOutImport
- defaultCaseOrder
- dupArg
- dupBranchBody
- dupCase
- dupSubExpr
- elseif
- emptyFallthrough
- ifElseChain
- importShadow
- indexAlloc
- methodExprCall
- nestingReduce
- offBy1
- ptrToRefParam
- regexpMust
- singleCaseSwitch
- sloppyLen
- switchTrue
- typeSwitchVar
- typeUnparen
- underef
- unlambda
- unnecessaryBlock
- unslice
- valSwap
- wrapperFunc
- yodaStyleExpr
# linters that we should / shouldn't run
linters:
enable-all: true
disable:
- gochecknoglobals
- lll
- maligned
- prealloc
- wsl
- gomnd
- godox
- goerr113
- testpackage
- godot
- interfacer
- goheader
- noctx
- gci
- errorlint
- nlreturn
- exhaustivestruct
- forbidigo
- wrapcheck
- varnamelen
- ireturn
- errorlint
- tparallel
- wrapcheck
- paralleltest
- makezero
- thelper
- cyclop
- revive
- importas
- gomoddirectives
- promlinter
- tagliatelle
- errname
- typecheck
# rules to deal with reported isues
issues:
# List of regexps of issue texts to exclude, empty list by default.
# But independently from this option we use default exclude patterns,
# it can be disabled by `exclude-use-default: false`. To list all
# excluded by default patterns execute `golangci-lint run --help`
exclude:
# Independently from option `exclude` we use default exclude patterns,
# it can be disabled by this option. To list all
# excluded by default patterns execute `golangci-lint run --help`.
# Default value for this option is true.
exclude-use-default: true
# Maximum issues count per one linter. Set to 0 to disable. Default is 50.
max-per-linter: 0
# Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
max-same-issues: 0
# Show only new issues: if there are unstaged changes or untracked files,
# only those changes are analyzed, else only changes in HEAD~ are analyzed.
# It's a super-useful option for integration of golangci-lint into existing
# large codebase. It's not practical to fix all existing issues at the moment
# of integration: much better don't allow issues in new code.
# Default is false.
new: false
# Show only new issues created after git revision `REV`
new-from-rev: "HEAD~1"

View File

@ -1,41 +0,0 @@
release:
prerelease: auto
name_template: "{{.ProjectName}} v{{.Version}}"
builds:
- env:
- CGO_ENABLED=0
goos:
- freebsd
- windows
- darwin
- linux
- dragonfly
- netbsd
- openbsd
goarch:
- amd64
- arm
- arm64
- 386
goarm:
- 6
- 7
ldflags:
- -s -w -X github.com/42wim/matterbridge/version.GitHash={{.ShortCommit}}
archives:
-
id: matterbridge
builds:
- matterbridge
name_template: "{{ .Binary }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
format: binary
files:
- none*
replacements:
386: 32bit
amd64: 64bit
checksum:
name_template: 'checksums.txt'

View File

@ -1,14 +1,11 @@
FROM alpine AS builder
FROM alpine:edge
ENTRYPOINT ["/bin/matterbridge"]
COPY . /go/src/matterbridge
RUN apk --no-cache add go git \
&& cd /go/src/matterbridge \
&& CGO_ENABLED=0 go build -mod vendor -ldflags "-X github.com/42wim/matterbridge/version.GitHash=$(git log --pretty=format:'%h' -n 1)" -o /bin/matterbridge
FROM alpine
RUN apk --no-cache add ca-certificates mailcap
COPY --from=builder /bin/matterbridge /bin/matterbridge
RUN mkdir /etc/matterbridge \
&& touch /etc/matterbridge/matterbridge.toml \
&& ln -sf /matterbridge.toml /etc/matterbridge/matterbridge.toml
ENTRYPOINT ["/bin/matterbridge", "-conf", "/etc/matterbridge/matterbridge.toml"]
COPY . /go/src/github.com/42wim/matterbridge
RUN apk update && apk add go git gcc musl-dev ca-certificates \
&& cd /go/src/github.com/42wim/matterbridge \
&& export GOPATH=/go \
&& go get \
&& go build -o /bin/matterbridge \
&& rm -rf /go \
&& apk del --purge git go gcc musl-dev

115
README-0.6.md Normal file
View File

@ -0,0 +1,115 @@
# matterbridge
Simple bridge between mattermost, IRC, XMPP, Gitter and Slack
* Relays public channel messages between mattermost, IRC, XMPP, Gitter and Slack. Pick and mix.
* Supports multiple channels.
* Matterbridge can also work with private groups on your mattermost.
Look at [matterbridge.conf.sample] (https://github.com/42wim/matterbridge/blob/master/matterbridge.conf.sample) for documentation and an example.
## Changelog
Since v0.6.1 support for XMPP, Gitter and Slack is added. More details in [changelog.md] (https://github.com/42wim/matterbridge/blob/master/changelog.md)
## Requirements:
Accounts to one of the supported bridges
* [Mattermost] (https://github.com/mattermost/platform/)
* [IRC] (http://www.mirc.com/servers.html)
* [XMPP] (https://jabber.org)
* [Gitter] (https://gitter.im)
* [Slack] (https://www.slack.com)
## binaries
Binaries can be found [here] (https://github.com/42wim/matterbridge/releases/)
* For use with mattermost 3.3.0+ [v0.6.1](https://github.com/42wim/matterircd/releases/tag/v0.6.1)
* For use with mattermost 3.0.0-3.2.0 [v0.5.0](https://github.com/42wim/matterircd/releases/tag/v0.5.0)
## Docker
Create your matterbridge.conf file locally eg in ```/tmp/matterbridge.conf```
```
docker run -ti -v /tmp/matterbridge.conf:/matterbridge.conf 42wim/matterbridge:0.6.1
```
## Compatibility
### Mattermost
* Matterbridge v0.6.1 works with mattermost 3.3.0 and higher [3.3.0 release](https://github.com/mattermost/platform/releases/tag/v3.3.0)
* Matterbridge v0.5.0 works with mattermost 3.0.0 - 3.2.0 [3.2.0 release](https://github.com/mattermost/platform/releases/tag/v3.2.0)
#### Webhooks version
* Configured incoming/outgoing [webhooks](https://www.mattermost.org/webhooks/) on your mattermost instance.
#### Plus (API) version
* A dedicated user(bot) on your mattermost instance.
## building
Go 1.6+ is required. Make sure you have [Go](https://golang.org/doc/install) properly installed, including setting up your [GOPATH] (https://golang.org/doc/code.html#GOPATH)
```
cd $GOPATH
go get github.com/42wim/matterbridge
```
You should now have matterbridge binary in the bin directory:
```
$ ls bin/
matterbridge
```
## running
1) Copy the matterbridge.conf.sample to matterbridge.conf in the same directory as the matterbridge binary.
2) Edit matterbridge.conf with the settings for your environment. See below for more config information.
3) Now you can run matterbridge.
```
Usage of ./matterbridge:
-conf string
config file (default "matterbridge.conf")
-debug
enable debug
-plus
running using API instead of webhooks (deprecated, set Plus flag in [general] config)
-version
show version
```
## config
### matterbridge
matterbridge looks for matterbridge.conf in current directory. (use -conf to specify another file)
Look at [matterbridge.conf.sample] (https://github.com/42wim/matterbridge/blob/master/matterbridge.conf.sample) for an example.
### mattermost
#### webhooks version
You'll have to configure the incoming and outgoing webhooks.
* incoming webhooks
Go to "account settings" - integrations - "incoming webhooks".
Choose a channel at "Add a new incoming webhook", this will create a webhook URL right below.
This URL should be set in the matterbridge.conf in the [mattermost] section (see above)
* outgoing webhooks
Go to "account settings" - integrations - "outgoing webhooks".
Choose a channel (the same as the one from incoming webhooks) and fill in the address and port of the server matterbridge will run on.
e.g. http://192.168.1.1:9999 (192.168.1.1:9999 is the BindAddress specified in [mattermost] section of matterbridge.conf)
#### plus version
You'll have to create a new dedicated user on your mattermost instance.
Specify the login and password in [mattermost] section of matterbridge.conf
## FAQ
Please look at [matterbridge.conf.sample] (https://github.com/42wim/matterbridge/blob/master/matterbridge.conf.sample) for more information first.
### Mattermost doesn't show the IRC nicks
If you're running the webhooks version, this can be fixed by either:
* enabling "override usernames". See [mattermost documentation](http://docs.mattermost.com/developer/webhooks-incoming.html#enabling-incoming-webhooks)
* setting ```PrefixMessagesWithNick``` to ```true``` in ```mattermost``` section of your matterbridge.conf.
If you're running the plus version you'll need to:
* setting ```PrefixMessagesWithNick``` to ```true``` in ```mattermost``` section of your matterbridge.conf.
Also look at the ```RemoteNickFormat``` setting.

433
README.md
View File

@ -1,391 +1,112 @@
<div align="center">
# matterbridge
Simple bridge between mattermost, IRC, XMPP, Gitter, Slack and Discord
![Matterbridge Logo](img/matterbridge-notext.gif)<br />
**A simple chat bridge**<br />
Letting people be where they want to be.<br />
<sub>Bridges between a growing number of protocols. Click below to demo or join the development chat.</sub>
* Relays public channel messages between multiple mattermost, IRC, XMPP, Gitter, Slack and Discord. Pick and mix.
* Supports multiple channels.
* Matterbridge can also work with private groups on your mattermost.
* Allow for bridging the same bridges, which means you can eg bridge between multiple mattermosts.
* The bridge is now a gateway which has support multiple in and out bridges. (and supports multiple gateways).
<sup>
Look at [matterbridge.toml.sample] (https://github.com/42wim/matterbridge/blob/master/matterbridge.toml.sample) for documentation and an example.
Look at [matterbridge.toml.simple] (https://github.com/42wim/matterbridge/blob/master/matterbridge.toml.simple) for a simple example.
[Discord][mb-discord] |
[Gitter][mb-gitter] |
[IRC][mb-irc] |
[Keybase][mb-keybase] |
[Matrix][mb-matrix] |
[Mattermost][mb-mattermost] |
[MSTeams][mb-msteams] |
[Rocket.Chat][mb-rocketchat] |
[Slack][mb-slack] |
[Telegram][mb-telegram] |
[Twitch][mb-twitch] |
[WhatsApp][mb-whatsapp] |
[XMPP][mb-xmpp] |
[Zulip][mb-zulip] |
And more...
</sup>
## Changelog
Since v0.7.0 the configuration has changed. More details in [changelog.md] (https://github.com/42wim/matterbridge/blob/master/changelog.md)
---
## Requirements
Accounts to one of the supported bridges
* [Mattermost] (https://github.com/mattermost/platform/)
* [IRC] (http://www.mirc.com/servers.html)
* [XMPP] (https://jabber.org)
* [Gitter] (https://gitter.im)
* [Slack] (https://slack.com)
* [Discord] (https://discordapp.com)
[![Download stable](https://img.shields.io/github/release/42wim/matterbridge.svg?label=download%20stable)](https://github.com/42wim/matterbridge/releases/latest)
[![Maintainability](https://api.codeclimate.com/v1/badges/82dff70ef2ba85a6173a/maintainability)](https://codeclimate.com/github/42wim/matterbridge/maintainability)
[![Test Coverage](https://api.codeclimate.com/v1/badges/82dff70ef2ba85a6173a/test_coverage)](https://codeclimate.com/github/42wim/matterbridge/test_coverage)<br />
<hr />
</div>
<div align="right"><sup>
**Note:** Matter<em>most</em> isn't required to run matter<em>bridge</em>.</sup></div>
<p>
<a href="https://www.digitalocean.com/">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" width="201px">
</a>
</p>
# Table of Contents
- [matterbridge](#matterbridge)
- [Table of Contents](#table-of-contents)
- [Features](#features)
- [Natively supported](#natively-supported)
- [3rd party via matterbridge api](#3rd-party-via-matterbridge-api)
- [API](#api)
- [Chat with us](#chat-with-us)
- [Screenshots](#screenshots)
- [Installing / upgrading](#installing--upgrading)
- [Binaries](#binaries)
- [Packages](#packages)
- [Building](#building)
- [Configuration](#configuration)
- [Basic configuration](#basic-configuration)
- [Settings](#settings)
- [Advanced configuration](#advanced-configuration)
- [Examples](#examples)
- [Bridge mattermost (off-topic) - irc (#testing)](#bridge-mattermost-off-topic---irc-testing)
- [Bridge slack (#general) - discord (general)](#bridge-slack-general---discord-general)
- [Running](#running)
- [Docker](#docker)
- [Systemd](#systemd)
- [Changelog](#changelog)
- [FAQ](#faq)
- [Related projects](#related-projects)
- [Articles / Tutorials](#articles--tutorials)
- [Thanks](#thanks)
## Features
- [Support bridging between any protocols](https://github.com/42wim/matterbridge/wiki/Features#support-bridging-between-any-protocols)
- [Support multiple gateways(bridges) for your protocols](https://github.com/42wim/matterbridge/wiki/Features#support-multiple-gatewaysbridges-for-your-protocols)
- [Message edits and deletes](https://github.com/42wim/matterbridge/wiki/Features#message-edits-and-deletes)
- Preserves threading when possible
- [Attachment / files handling](https://github.com/42wim/matterbridge/wiki/Features#attachment--files-handling)
- [Username and avatar spoofing](https://github.com/42wim/matterbridge/wiki/Features#username-and-avatar-spoofing)
- [Private groups](https://github.com/42wim/matterbridge/wiki/Features#private-groups)
- [API](https://github.com/42wim/matterbridge/wiki/Features#api)
### Natively supported
- [Discord](https://discordapp.com)
- [Gitter](https://gitter.im)
- [IRC](http://www.mirc.com/servers.html)
- [Keybase](https://keybase.io)
- [Matrix](https://matrix.org)
- [Mattermost](https://github.com/mattermost/mattermost-server/)
- [Microsoft Teams](https://teams.microsoft.com)
- [Mumble](https://www.mumble.info/)
- [Nextcloud Talk](https://nextcloud.com/talk/)
- [Rocket.chat](https://rocket.chat)
- [Slack](https://slack.com)
- [Ssh-chat](https://github.com/shazow/ssh-chat)
- ~~[Steam](https://store.steampowered.com/)~~
- Not supported anymore, see [here](https://github.com/Philipp15b/go-steam/issues/94) for more info.
- [Telegram](https://telegram.org)
- [Twitch](https://twitch.tv)
- [VK](https://vk.com/)
- [WhatsApp](https://www.whatsapp.com/)
- [XMPP](https://xmpp.org)
- [Zulip](https://zulipchat.com)
### 3rd party via matterbridge api
- [Discourse](https://github.com/DeclanHoare/matterbabble)
- [Facebook messenger](https://github.com/powerjungle/fbridge-asyncio)
- [Facebook messenger](https://github.com/VictorNine/fbridge)
- [Minecraft](https://github.com/elytra/MatterLink)
- [Minecraft](https://github.com/raws/mattercraft)
- [Minecraft](https://gitlab.com/Programie/MatterBukkit)
- [Reddit](https://github.com/bonehurtingjuice/mattereddit)
- [Counter-Strike, half-life and more](https://forums.alliedmods.net/showthread.php?t=319430)
- [MatterAMXX](https://github.com/GabeIggy/MatterAMXX)
- [Vintage Story](https://github.com/NikkyAI/vs-matterbridge)
### API
The API is basic at the moment.
More info and examples on the [wiki](https://github.com/42wim/matterbridge/wiki/Api).
Used by the projects below. Feel free to make a PR to add your project to this list.
- [MatterLink](https://github.com/elytra/MatterLink) (Matterbridge link for Minecraft Forge server chat, archived)
- [MatterCraft](https://github.com/raws/mattercraft) (Matterbridge link for Minecraft Forge server chat)
- [MatterBukkit](https://gitlab.com/Programie/MatterBukkit) (Matterbridge link for Minecraft Bukkit/Spigot server chat)
- [pyCord](https://github.com/NikkyAI/pyCord) (crossplatform chatbot)
- [Mattereddit](https://github.com/bonehurtingjuice/mattereddit) (Reddit chat support)
- [fbridge-asyncio](https://github.com/powerjungle/fbridge-asyncio) (Facebook messenger support)
- [fbridge](https://github.com/VictorNine/fbridge) (Facebook messenger support)
- [matterbabble](https://github.com/DeclanHoare/matterbabble) (Discourse support)
- [MatterAMXX](https://forums.alliedmods.net/showthread.php?t=319430) (Counter-Strike, half-life and more via AMXX mod)
- [Vintage Story](https://github.com/NikkyAI/vs-matterbridge)
## Chat with us
Questions or want to test on your favorite platform? Join below:
- [Discord][mb-discord]
- [Gitter][mb-gitter]
- [IRC][mb-irc]
- [Keybase][mb-keybase]
- [Matrix][mb-matrix]
- [Mattermost][mb-mattermost]
- [Rocket.Chat][mb-rocketchat]
- [Slack][mb-slack]
- [Telegram][mb-telegram]
- [Twitch][mb-twitch]
- [XMPP][mb-xmpp] (matterbridge@conference.jabber.de)
- [Zulip][mb-zulip]
## Screenshots
See <https://github.com/42wim/matterbridge/wiki>
## Installing / upgrading
### Binaries
- Latest stable release [v1.23.2](https://github.com/42wim/matterbridge/releases/latest)
- Development releases (follows master) can be downloaded [here](https://github.com/42wim/matterbridge/actions) selecting the latest green build and then artifacts.
To install or upgrade just download the latest [binary](https://github.com/42wim/matterbridge/releases/latest). On \*nix platforms you may need to make the binary executable - you can do this by running `chmod a+x` on the binary (example: `chmod a+x matterbridge-1.20.0-linux-64bit`). After downloading (and making the binary executable, if necessary), follow the instructions on the [howto](https://github.com/42wim/matterbridge/wiki/How-to-create-your-config) for a step by step walkthrough for creating your configuration.
### Packages
- [Overview](https://repology.org/metapackage/matterbridge/versions)
- [snap](https://snapcraft.io/matterbridge)
- [scoop](https://github.com/42wim/scoop-bucket)
## Building
Most people just want to use binaries, you can find those [here](https://github.com/42wim/matterbridge/releases/latest)
If you really want to build from source, follow these instructions:
Go 1.17+ is required. Make sure you have [Go](https://golang.org/doc/install) properly installed.
To install the latest stable run:
```bash
go install github.com/42wim/matterbridge
## Docker
Create your matterbridge.toml file locally eg in ```/tmp/matterbridge.toml```
```
docker run -ti -v /tmp/matterbridge.toml:/matterbridge.toml 42wim/matterbridge
```
To install the latest dev run:
## binaries
Binaries can be found [here] (https://github.com/42wim/matterbridge/releases/)
* For use with mattermost 3.5.0+ [v0.8.0](https://github.com/42wim/matterircd/releases/tag/v0.8.0)
* For use with mattermost 3.3.0 - 3.4.0 [v0.7.0](https://github.com/42wim/matterircd/releases/tag/v0.7.0)
* For use with mattermost 3.0.0 - 3.2.0 [v0.5.0](https://github.com/42wim/matterircd/releases/tag/v0.5.0) (not maintained anymore)
```bash
go install github.com/42wim/matterbridge@master
## Compatibility
### Mattermost
* Matterbridge v0.8.0 works with mattermost 3.5.0+ [3.5.0 release](https://github.com/mattermost/platform/releases/tag/v3.5.0)
* Matterbridge v0.7.0 works with mattermost 3.3.0 - 3.4.0 [3.4.0 release](https://github.com/mattermost/platform/releases/tag/v3.4.0)
* Matterbridge v0.5.0 works with mattermost 3.0.0 - 3.2.0 [3.2.0 release](https://github.com/mattermost/platform/releases/tag/v3.2.0)
#### Webhooks version
* Configured incoming/outgoing [webhooks](https://www.mattermost.org/webhooks/) on your mattermost instance.
#### API version
* A dedicated user(bot) on your mattermost instance.
## building
Go 1.6+ is required. Make sure you have [Go](https://golang.org/doc/install) properly installed, including setting up your [GOPATH] (https://golang.org/doc/code.html#GOPATH)
```
cd $GOPATH
go get github.com/42wim/matterbridge
```
You should now have matterbridge binary in the ~/go/bin directory:
You should now have matterbridge binary in the bin directory:
```bash
$ ls ~/go/bin/
```
$ ls bin/
matterbridge
```
## Configuration
## running
1) Copy the matterbridge.conf.sample to matterbridge.conf in the same directory as the matterbridge binary.
2) Edit matterbridge.conf with the settings for your environment. See below for more config information.
3) Now you can run matterbridge.
### Basic configuration
See [howto](https://github.com/42wim/matterbridge/wiki/How-to-create-your-config) for a step by step walkthrough for creating your configuration.
### Settings
All possible [settings](https://github.com/42wim/matterbridge/wiki/Settings) for each bridge.
### Advanced configuration
- [matterbridge.toml.sample](https://github.com/42wim/matterbridge/blob/master/matterbridge.toml.sample) for documentation and an example.
### Examples
#### Bridge mattermost (off-topic) - irc (#testing)
```toml
[irc]
[irc.libera]
Server="irc.libera.chat:6667"
Nick="yourbotname"
[mattermost]
[mattermost.work]
Server="yourmattermostserver.tld"
Team="yourteam"
Login="yourlogin"
Password="yourpass"
PrefixMessagesWithNick=true
RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
[[gateway]]
name="mygateway"
enable=true
[[gateway.inout]]
account="irc.libera"
channel="#testing"
[[gateway.inout]]
account="mattermost.work"
channel="off-topic"
```
#### Bridge slack (#general) - discord (general)
```toml
[slack]
[slack.test]
Token="yourslacktoken"
PrefixMessagesWithNick=true
[discord]
[discord.test]
Token="yourdiscordtoken"
Server="yourdiscordservername"
[general]
RemoteNickFormat="[{PROTOCOL}/{BRIDGE}] <{NICK}> "
[[gateway]]
name = "mygateway"
enable=true
[[gateway.inout]]
account = "discord.test"
channel="general"
[[gateway.inout]]
account ="slack.test"
channel = "general"
```
## Running
See [howto](https://github.com/42wim/matterbridge/wiki/How-to-create-your-config) for a step by step walkthrough for creating your configuration.
```bash
Usage of ./matterbridge:
-conf string
config file (default "matterbridge.toml")
-debug
enable debug
-gops
enable gops agent
-version
show version
```
### Docker
## config
### matterbridge
matterbridge looks for matterbridge.toml in current directory. (use -conf to specify another file)
Please take a look at the [Docker Wiki page](https://github.com/42wim/matterbridge/wiki/Deploy:-Docker) for more information.
Look at [matterbridge.toml.sample] (https://github.com/42wim/matterbridge/blob/master/matterbridge.toml.sample) for an example.
### Systemd
### mattermost
#### webhooks version
You'll have to configure the incoming and outgoing webhooks.
Please take a look at the [Service Files page](https://github.com/42wim/matterbridge/wiki/Service-files) for more information.
* incoming webhooks
Go to "account settings" - integrations - "incoming webhooks".
Choose a channel at "Add a new incoming webhook", this will create a webhook URL right below.
This URL should be set in the matterbridge.conf in the [mattermost] section (see above)
## Changelog
* outgoing webhooks
Go to "account settings" - integrations - "outgoing webhooks".
Choose a channel (the same as the one from incoming webhooks) and fill in the address and port of the server matterbridge will run on.
See [changelog.md](https://github.com/42wim/matterbridge/blob/master/changelog.md)
e.g. http://192.168.1.1:9999 (192.168.1.1:9999 is the BindAddress specified in [mattermost] section of matterbridge.conf)
## FAQ
Please look at [matterbridge.toml.sample] (https://github.com/42wim/matterbridge/blob/master/matterbridge.toml.sample) for more information first.
### Mattermost doesn't show the IRC nicks
If you're running the webhooks version, this can be fixed by either:
* enabling "override usernames". See [mattermost documentation](http://docs.mattermost.com/developer/webhooks-incoming.html#enabling-incoming-webhooks)
* setting ```PrefixMessagesWithNick``` to ```true``` in ```mattermost``` section of your matterbridge.toml.
See [FAQ](https://github.com/42wim/matterbridge/wiki/FAQ)
If you're running the plus version you'll need to:
* setting ```PrefixMessagesWithNick``` to ```true``` in ```mattermost``` section of your matterbridge.toml.
## Related projects
- [jwflory/ansible-role-matterbridge](https://galaxy.ansible.com/jwflory/matterbridge) (Ansible role to simplify deploying Matterbridge)
- [matterbridge autoconfig](https://github.com/patcon/matterbridge-autoconfig)
- [matterbridge config viewer](https://github.com/patcon/matterbridge-heroku-viewer)
- [matterbridge-heroku](https://github.com/cadecairos/matterbridge-heroku)
- [mattereddit](https://github.com/bonehurtingjuice/mattereddit)
- [matterlink](https://github.com/elytra/MatterLink)
- [mattermost-plugin](https://github.com/matterbridge/mattermost-plugin) - Run matterbridge as a plugin in mattermost
- [pyCord](https://github.com/NikkyAI/pyCord) (crossplatform chatbot)
- [fbridge](https://github.com/VictorNine/fbridge) (Facebook messenger support)
- [isla](https://github.com/alphachung/isla) (Bot for Discord-Telegram groups used alongside matterbridge)
- [matterbabble](https://github.com/DeclanHoare/matterbabble) (Connect Discourse threads to Matterbridge)
- [nextcloud talk](https://github.com/nextcloud/talk_matterbridge) (Integrates matterbridge in Nextcloud Talk)
- [mattercraft](https://github.com/raws/mattercraft) (Minecraft bridge)
- [vs-matterbridge](https://github.com/NikkyAI/vs-matterbridge) (Vintage Story bridge)
## Articles / Tutorials
- [matterbridge on kubernetes](https://medium.freecodecamp.org/using-kubernetes-to-deploy-a-chat-gateway-or-when-technology-works-like-its-supposed-to-a169a8cd69a3)
- <https://mattermost.com/blog/connect-irc-to-mattermost/>
- <https://blog.valvin.fr/2016/09/17/mattermost-et-un-channel-irc-cest-possible/>
- <https://blog.brightscout.com/top-10-mattermost-integrations/>
- <https://www.algoo.fr/blog/2018/01/19/recouvrez-votre-liberte-en-quittant-slack-pour-un-mattermost-auto-heberge/>
- <https://kopano.com/blog/matterbridge-bridging-mattermost-chat/>
- <https://www.stitcher.com/s/?eid=52382713>
- <https://daniele.tech/2019/02/how-to-use-matterbridge-to-connect-2-different-slack-workspaces/>
- <https://userlinux.net/mattermost-and-matterbridge.html>
- <https://nextcloud.com/blog/bridging-chat-services-in-talk/>
- <https://minecraftchest1.wordpress.com/2021/06/05/how-to-install-and-setup-matterbridge/>
- Youtube: [whatsapp - telegram bridging](https://www.youtube.com/watch?v=W-VXISoKtNc)
## Thanks
<p>This project is supported by:</p>
<p>
<a href="https://www.digitalocean.com/">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px">
</a>
</p>
Matterbridge wouldn't exist without these libraries:
- discord - <https://github.com/bwmarrin/discordgo>
- echo - <https://github.com/labstack/echo>
- gitter - <https://github.com/sromku/go-gitter>
- gops - <https://github.com/google/gops>
- gozulipbot - <https://github.com/ifo/gozulipbot>
- gumble - <https://github.com/layeh/gumble>
- irc - <https://github.com/lrstanley/girc>
- keybase - <https://github.com/keybase/go-keybase-chat-bot>
- matrix - <https://github.com/matrix-org/gomatrix>
- mattermost - <https://github.com/mattermost/mattermost-server>
- msgraph.go - <https://github.com/yaegashi/msgraph.go>
- mumble - <https://github.com/layeh/gumble>
- nctalk - <https://github.com/gary-kim/go-nc-talk>
- slack - <https://github.com/nlopes/slack>
- sshchat - <https://github.com/shazow/ssh-chat>
- steam - <https://github.com/Philipp15b/go-steam>
- telegram - <https://github.com/go-telegram-bot-api/telegram-bot-api>
- tengo - <https://github.com/d5/tengo>
- vk - <https://github.com/SevereCloud/vksdk>
- whatsapp - <https://github.com/Rhymen/go-whatsapp>
- xmpp - <https://github.com/mattn/go-xmpp>
- zulip - <https://github.com/ifo/gozulipbot>
<!-- Links -->
[mb-discord]: https://discord.gg/AkKPtrQ
[mb-gitter]: https://gitter.im/42wim/matterbridge
[mb-irc]: https://web.libera.chat/#matterbridge
[mb-keybase]: https://keybase.io/team/matterbridge
[mb-matrix]: https://riot.im/app/#/room/#matterbridge:matrix.org
[mb-mattermost]: https://framateam.org/signup_user_complete/?id=tfqm33ggop8x3qgu4boeieta6e
[mb-msteams]: https://teams.microsoft.com/join/hj92x75gd3y7
[mb-rocketchat]: https://open.rocket.chat/channel/matterbridge
[mb-slack]: https://join.slack.com/t/matterbridgechat/shared_invite/zt-2ourq2h2-7YvyYBq2WFGC~~zEzA68_Q
[mb-telegram]: https://t.me/Matterbridge
[mb-twitch]: https://www.twitch.tv/matterbridge
[mb-whatsapp]: https://www.whatsapp.com/
[mb-xmpp]: https://inverse.chat/
[mb-zulip]: https://matterbridge.zulipchat.com/register/
Also look at the ```RemoteNickFormat``` setting.

View File

@ -1,207 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"sync"
"time"
"gopkg.in/olahol/melody.v1"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
ring "github.com/zfjagann/golang-ring"
)
type API struct {
Messages ring.Ring
sync.RWMutex
*bridge.Config
mrouter *melody.Melody
}
type Message struct {
Text string `json:"text"`
Username string `json:"username"`
UserID string `json:"userid"`
Avatar string `json:"avatar"`
Gateway string `json:"gateway"`
}
func New(cfg *bridge.Config) bridge.Bridger {
b := &API{Config: cfg}
e := echo.New()
e.HideBanner = true
e.HidePort = true
b.mrouter = melody.New()
b.mrouter.HandleMessage(func(s *melody.Session, msg []byte) {
message := config.Message{}
err := json.Unmarshal(msg, &message)
if err != nil {
b.Log.Errorf("failed to decode message from byte[] '%s'", string(msg))
return
}
b.handleWebsocketMessage(message, s)
})
b.mrouter.HandleConnect(func(session *melody.Session) {
greet := b.getGreeting()
data, err := json.Marshal(greet)
if err != nil {
b.Log.Errorf("failed to encode message '%v'", greet)
return
}
err = session.Write(data)
if err != nil {
b.Log.Errorf("failed to write message '%s'", string(data))
return
}
// TODO: send message history buffer from `b.Messages` here
})
b.Messages = ring.Ring{}
if b.GetInt("Buffer") != 0 {
b.Messages.SetCapacity(b.GetInt("Buffer"))
}
if b.GetString("Token") != "" {
e.Use(middleware.KeyAuth(func(key string, c echo.Context) (bool, error) {
return key == b.GetString("Token"), nil
}))
}
// Set RemoteNickFormat to a sane default
if !b.IsKeySet("RemoteNickFormat") {
b.Log.Debugln("RemoteNickFormat is unset, defaulting to \"{NICK}\"")
b.Config.Config.Viper().Set(b.GetConfigKey("RemoteNickFormat"), "{NICK}")
}
e.GET("/api/health", b.handleHealthcheck)
e.GET("/api/messages", b.handleMessages)
e.GET("/api/stream", b.handleStream)
e.GET("/api/websocket", b.handleWebsocket)
e.POST("/api/message", b.handlePostMessage)
go func() {
if b.GetString("BindAddress") == "" {
b.Log.Fatalf("No BindAddress configured.")
}
b.Log.Infof("Listening on %s", b.GetString("BindAddress"))
b.Log.Fatal(e.Start(b.GetString("BindAddress")))
}()
return b
}
func (b *API) Connect() error {
return nil
}
func (b *API) Disconnect() error {
return nil
}
func (b *API) JoinChannel(channel config.ChannelInfo) error {
return nil
}
func (b *API) Send(msg config.Message) (string, error) {
b.Lock()
defer b.Unlock()
// ignore delete messages
if msg.Event == config.EventMsgDelete {
return "", nil
}
b.Log.Debugf("enqueueing message from %s on ring buffer", msg.Username)
b.Messages.Enqueue(msg)
data, err := json.Marshal(msg)
if err != nil {
b.Log.Errorf("failed to encode message '%s'", msg)
}
_ = b.mrouter.Broadcast(data)
return "", nil
}
func (b *API) handleHealthcheck(c echo.Context) error {
return c.String(http.StatusOK, "OK")
}
func (b *API) handlePostMessage(c echo.Context) error {
message := config.Message{}
if err := c.Bind(&message); err != nil {
return err
}
// these values are fixed
message.Channel = "api"
message.Protocol = "api"
message.Account = b.Account
message.ID = ""
message.Timestamp = time.Now()
b.Log.Debugf("Sending message from %s on %s to gateway", message.Username, "api")
b.Remote <- message
return c.JSON(http.StatusOK, message)
}
func (b *API) handleMessages(c echo.Context) error {
b.Lock()
defer b.Unlock()
c.JSONPretty(http.StatusOK, b.Messages.Values(), " ")
b.Messages = ring.Ring{}
return nil
}
func (b *API) getGreeting() config.Message {
return config.Message{
Event: config.EventAPIConnected,
Timestamp: time.Now(),
}
}
func (b *API) handleStream(c echo.Context) error {
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
c.Response().WriteHeader(http.StatusOK)
greet := b.getGreeting()
if err := json.NewEncoder(c.Response()).Encode(greet); err != nil {
return err
}
c.Response().Flush()
for {
// TODO: this causes issues, messages should be broadcasted to all connected clients
msg := b.Messages.Dequeue()
if msg != nil {
if err := json.NewEncoder(c.Response()).Encode(msg); err != nil {
return err
}
c.Response().Flush()
}
time.Sleep(200 * time.Millisecond)
}
}
func (b *API) handleWebsocketMessage(message config.Message, s *melody.Session) {
message.Channel = "api"
message.Protocol = "api"
message.Account = b.Account
message.ID = ""
message.Timestamp = time.Now()
data, err := json.Marshal(message)
if err != nil {
b.Log.Errorf("failed to encode message for loopback '%v'", message)
return
}
_ = b.mrouter.BroadcastOthers(data, s)
b.Log.Debugf("Sending websocket message from %s on %s to gateway", message.Username, "api")
b.Remote <- message
}
func (b *API) handleWebsocket(c echo.Context) error {
err := b.mrouter.HandleRequest(c.Response(), c.Request())
if err != nil {
b.Log.Errorf("error in websocket handling '%v'", err)
return err
}
return nil
}

View File

@ -1,135 +1,45 @@
package bridge
import (
"log"
"strings"
"sync"
"time"
"github.com/42wim/matterbridge/bridge/config"
"github.com/sirupsen/logrus"
"github.com/42wim/matterbridge/bridge/discord"
"github.com/42wim/matterbridge/bridge/gitter"
"github.com/42wim/matterbridge/bridge/irc"
"github.com/42wim/matterbridge/bridge/mattermost"
"github.com/42wim/matterbridge/bridge/slack"
"github.com/42wim/matterbridge/bridge/xmpp"
"strings"
)
type Bridger interface {
Send(msg config.Message) (string, error)
type Bridge interface {
Send(msg config.Message) error
Name() string
Connect() error
JoinChannel(channel config.ChannelInfo) error
Disconnect() error
FullOrigin() string
Origin() string
Protocol() string
JoinChannel(channel string) error
}
type Bridge struct {
Bridger
*sync.RWMutex
Name string
Account string
Protocol string
Channels map[string]config.ChannelInfo
Joined map[string]bool
ChannelMembers *config.ChannelMembers
Log *logrus.Entry
Config config.Config
General *config.Protocol
}
type Config struct {
*Bridge
Remote chan config.Message
}
// Factory is the factory function to create a bridge
type Factory func(*Config) Bridger
func New(bridge *config.Bridge) *Bridge {
func New(cfg *config.Config, bridge *config.Bridge, c chan config.Message) Bridge {
accInfo := strings.Split(bridge.Account, ".")
if len(accInfo) != 2 {
log.Fatalf("config failure, account incorrect: %s", bridge.Account)
}
protocol := accInfo[0]
name := accInfo[1]
return &Bridge{
RWMutex: new(sync.RWMutex),
Channels: make(map[string]config.ChannelInfo),
Name: name,
Protocol: protocol,
Account: bridge.Account,
Joined: make(map[string]bool),
}
}
func (b *Bridge) JoinChannels() error {
return b.joinChannels(b.Channels, b.Joined)
}
// SetChannelMembers sets the newMembers to the bridge ChannelMembers
func (b *Bridge) SetChannelMembers(newMembers *config.ChannelMembers) {
b.Lock()
b.ChannelMembers = newMembers
b.Unlock()
}
func (b *Bridge) joinChannels(channels map[string]config.ChannelInfo, exists map[string]bool) error {
for ID, channel := range channels {
if !exists[ID] {
b.Log.Infof("%s: joining %s (ID: %s)", b.Account, channel.Name, ID)
time.Sleep(time.Duration(b.GetInt("JoinDelay")) * time.Millisecond)
err := b.JoinChannel(channel)
if err != nil {
return err
}
exists[ID] = true
}
// override config from environment
config.OverrideCfgFromEnv(cfg, protocol, name)
switch protocol {
case "mattermost":
return bmattermost.New(cfg.Mattermost[name], name, c)
case "irc":
return birc.New(cfg.IRC[name], name, c)
case "gitter":
return bgitter.New(cfg.Gitter[name], name, c)
case "slack":
return bslack.New(cfg.Slack[name], name, c)
case "xmpp":
return bxmpp.New(cfg.Xmpp[name], name, c)
case "discord":
return bdiscord.New(cfg.Discord[name], name, c)
}
return nil
}
func (b *Bridge) GetConfigKey(key string) string {
return b.Account + "." + key
}
func (b *Bridge) IsKeySet(key string) bool {
return b.Config.IsKeySet(b.GetConfigKey(key)) || b.Config.IsKeySet("general."+key)
}
func (b *Bridge) GetBool(key string) bool {
val, ok := b.Config.GetBool(b.GetConfigKey(key))
if !ok {
val, _ = b.Config.GetBool("general." + key)
}
return val
}
func (b *Bridge) GetInt(key string) int {
val, ok := b.Config.GetInt(b.GetConfigKey(key))
if !ok {
val, _ = b.Config.GetInt("general." + key)
}
return val
}
func (b *Bridge) GetString(key string) string {
val, ok := b.Config.GetString(b.GetConfigKey(key))
if !ok {
val, _ = b.Config.GetString("general." + key)
}
return val
}
func (b *Bridge) GetStringSlice(key string) []string {
val, ok := b.Config.GetStringSlice(b.GetConfigKey(key))
if !ok {
val, _ = b.Config.GetStringSlice("general." + key)
}
return val
}
func (b *Bridge) GetStringSlice2D(key string) [][]string {
val, ok := b.Config.GetStringSlice2D(b.GetConfigKey(key))
if !ok {
val, _ = b.Config.GetStringSlice2D("general." + key)
}
return val
}

View File

@ -1,192 +1,57 @@
package config
import (
"bytes"
"io/ioutil"
"github.com/BurntSushi/toml"
"log"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
const (
EventJoinLeave = "join_leave"
EventTopicChange = "topic_change"
EventFailure = "failure"
EventFileFailureSize = "file_failure_size"
EventAvatarDownload = "avatar_download"
EventRejoinChannels = "rejoin_channels"
EventUserAction = "user_action"
EventMsgDelete = "msg_delete"
EventAPIConnected = "api_connected"
EventUserTyping = "user_typing"
EventGetChannelMembers = "get_channel_members"
EventNoticeIRC = "notice_irc"
)
const ParentIDNotFound = "msg-parent-not-found"
type Message struct {
Text string `json:"text"`
Channel string `json:"channel"`
Username string `json:"username"`
UserID string `json:"userid"` // userid on the bridge
Avatar string `json:"avatar"`
Account string `json:"account"`
Event string `json:"event"`
Protocol string `json:"protocol"`
Gateway string `json:"gateway"`
ParentID string `json:"parent_id"`
Timestamp time.Time `json:"timestamp"`
ID string `json:"id"`
Extra map[string][]interface{}
Text string
Channel string
Username string
Origin string
FullOrigin string
Protocol string
Avatar string
}
func (m Message) ParentNotFound() bool {
return m.ParentID == ParentIDNotFound
}
func (m Message) ParentValid() bool {
return m.ParentID != "" && !m.ParentNotFound()
}
type FileInfo struct {
Name string
Data *[]byte
Comment string
URL string
Size int64
Avatar bool
SHA string
}
type ChannelInfo struct {
Name string
Account string
Direction string
ID string
SameChannel map[string]bool
Options ChannelOptions
}
type ChannelMember struct {
Username string
Nick string
UserID string
ChannelID string
ChannelName string
}
type ChannelMembers []ChannelMember
type Protocol struct {
AllowMention []string // discord
AuthCode string // steam
BindAddress string // mattermost, slack // DEPRECATED
Buffer int // api
Charset string // irc
ClientID string // msteams
ColorNicks bool // only irc for now
Debug bool // general
DebugLevel int // only for irc now
DisableWebPagePreview bool // telegram
EditSuffix string // mattermost, slack, discord, telegram, gitter
EditDisable bool // mattermost, slack, discord, telegram, gitter
HTMLDisable bool // matrix
IconURL string // mattermost, slack
IgnoreFailureOnStart bool // general
IgnoreNicks string // all protocols
IgnoreMessages string // all protocols
Jid string // xmpp
JoinDelay string // all protocols
Label string // all protocols
Login string // mattermost, matrix
LogFile string // general
MediaDownloadBlackList []string
MediaDownloadPath string // Basically MediaServerUpload, but instead of uploading it, just write it to a file on the same server.
MediaDownloadSize int // all protocols
MediaServerDownload string
MediaServerUpload string
MediaConvertTgs string // telegram
MediaConvertWebPToPNG bool // telegram
MessageDelay int // IRC, time in millisecond to wait between messages
MessageFormat string // telegram
MessageLength int // IRC, max length of a message allowed
MessageQueue int // IRC, size of message queue for flood control
MessageSplit bool // IRC, split long messages with newlines on MessageLength instead of clipping
Muc string // xmpp
MxID string // matrix
Name string // all protocols
Nick string // all protocols
NickFormatter string // mattermost, slack
NickServNick string // IRC
NickServUsername string // IRC
NickServPassword string // IRC
NicksPerRow int // mattermost, slack
NoHomeServerSuffix bool // matrix
NoSendJoinPart bool // all protocols
NoTLS bool // mattermost, xmpp
Password string // IRC,mattermost,XMPP,matrix
PrefixMessagesWithNick bool // mattemost, slack
PreserveThreading bool // slack
Protocol string // all protocols
QuoteDisable bool // telegram
QuoteFormat string // telegram
QuoteLengthLimit int // telegram
RealName string // IRC
RejoinDelay int // IRC
ReplaceMessages [][]string // all protocols
ReplaceNicks [][]string // all protocols
RemoteNickFormat string // all protocols
RunCommands []string // IRC
Server string // IRC,mattermost,XMPP,discord,matrix
SessionFile string // msteams,whatsapp
ShowJoinPart bool // all protocols
ShowTopicChange bool // slack
ShowUserTyping bool // slack
ShowEmbeds bool // discord
SkipTLSVerify bool // IRC, mattermost
SkipVersionCheck bool // mattermost
StripNick bool // all protocols
StripMarkdown bool // irc
SyncTopic bool // slack
TengoModifyMessage string // general
Team string // mattermost, keybase
TeamID string // msteams
TenantID string // msteams
Token string // gitter, slack, discord, api, matrix
Topic string // zulip
URL string // mattermost, slack // DEPRECATED
UseAPI bool // mattermost, slack
UseLocalAvatar []string // discord
UseSASL bool // IRC
UseTLS bool // IRC
UseDiscriminator bool // discord
UseFirstName bool // telegram
UseUserName bool // discord, matrix
UseInsecureURL bool // telegram
UserName string // IRC
VerboseJoinPart bool // IRC
WebhookBindAddress string // mattermost, slack
WebhookURL string // mattermost, slack
}
type ChannelOptions struct {
Key string // irc, xmpp
WebhookURL string // discord
Topic string // zulip
BindAddress string // mattermost, slack
IconURL string // mattermost, slack
IgnoreNicks string // all protocols
Jid string // xmpp
Login string // mattermost
Muc string // xmpp
Name string // all protocols
Nick string // all protocols
NickFormatter string // mattermost, slack
NickServNick string // IRC
NickServPassword string // IRC
NicksPerRow int // mattermost, slack
NoTLS bool // mattermost
Password string // IRC,mattermost,XMPP
PrefixMessagesWithNick bool // mattemost, slack
Protocol string //all protocols
MessageQueue int // IRC, size of message queue for flood control
MessageDelay int // IRC, time in millisecond to wait between messages
RemoteNickFormat string // all protocols
Server string // IRC,mattermost,XMPP,discord
ShowJoinPart bool // all protocols
SkipTLSVerify bool // IRC, mattermost
Team string // mattermost
Token string // gitter, slack, discord
URL string // mattermost, slack
UseAPI bool // mattermost, slack
UseSASL bool // IRC
UseTLS bool // IRC
}
type Bridge struct {
Account string
Channel string
Options ChannelOptions
SameChannel bool
Account string
Channel string
}
type Gateway struct {
@ -194,14 +59,6 @@ type Gateway struct {
Enable bool
In []Bridge
Out []Bridge
InOut []Bridge
}
type Tengo struct {
InMessage string
Message string
RemoteNickFormat string
OutMessage string
}
type SameChannelGateway struct {
@ -211,229 +68,74 @@ type SameChannelGateway struct {
Accounts []string
}
type BridgeValues struct {
API map[string]Protocol
type Config struct {
IRC map[string]Protocol
Mattermost map[string]Protocol
Matrix map[string]Protocol
Slack map[string]Protocol
SlackLegacy map[string]Protocol
Steam map[string]Protocol
Gitter map[string]Protocol
XMPP map[string]Protocol
Xmpp map[string]Protocol
Discord map[string]Protocol
Telegram map[string]Protocol
Rocketchat map[string]Protocol
SSHChat map[string]Protocol
WhatsApp map[string]Protocol // TODO is this struct used? Search for "SlackLegacy" for example didn't return any results
Zulip map[string]Protocol
Keybase map[string]Protocol
Mumble map[string]Protocol
General Protocol
Tengo Tengo
Gateway []Gateway
SameChannelGateway []SameChannelGateway
}
type Config interface {
Viper() *viper.Viper
BridgeValues() *BridgeValues
IsKeySet(key string) bool
GetBool(key string) (bool, bool)
GetInt(key string) (int, bool)
GetString(key string) (string, bool)
GetStringSlice(key string) ([]string, bool)
GetStringSlice2D(key string) ([][]string, bool)
}
type config struct {
sync.RWMutex
logger *logrus.Entry
v *viper.Viper
cv *BridgeValues
}
// NewConfig instantiates a new configuration based on the specified configuration file path.
func NewConfig(rootLogger *logrus.Logger, cfgfile string) Config {
logger := rootLogger.WithFields(logrus.Fields{"prefix": "config"})
viper.SetConfigFile(cfgfile)
input, err := ioutil.ReadFile(cfgfile)
if err != nil {
logger.Fatalf("Failed to read configuration file: %#v", err)
func NewConfig(cfgfile string) *Config {
var cfg Config
if _, err := toml.DecodeFile(cfgfile, &cfg); err != nil {
log.Fatal(err)
}
return &cfg
}
cfgtype := detectConfigType(cfgfile)
mycfg := newConfigFromString(logger, input, cfgtype)
if mycfg.cv.General.LogFile != "" {
logfile, err := os.OpenFile(mycfg.cv.General.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err == nil {
logger.Info("Opening log file ", mycfg.cv.General.LogFile)
rootLogger.Out = logfile
} else {
logger.Warn("Failed to open ", mycfg.cv.General.LogFile)
func OverrideCfgFromEnv(cfg *Config, protocol string, account string) {
var protoCfg Protocol
val := reflect.ValueOf(cfg).Elem()
// loop over the Config struct
for i := 0; i < val.NumField(); i++ {
typeField := val.Type().Field(i)
// look for the protocol map (both lowercase)
if strings.ToLower(typeField.Name) == protocol {
// get the Protocol struct from the map
data := val.Field(i).MapIndex(reflect.ValueOf(account))
protoCfg = data.Interface().(Protocol)
protoStruct := reflect.ValueOf(&protoCfg).Elem()
// loop over the found protocol struct
for i := 0; i < protoStruct.NumField(); i++ {
typeField := protoStruct.Type().Field(i)
// build our environment key (eg MATTERBRIDGE_MATTERMOST_WORK_LOGIN)
key := "matterbridge_" + protocol + "_" + account + "_" + typeField.Name
key = strings.ToUpper(key)
// search the environment
res := os.Getenv(key)
// if it exists and the current field is a string
// then update the current field
if res != "" {
fieldVal := protoStruct.Field(i)
if fieldVal.Kind() == reflect.String {
log.Printf("config: overriding %s from env with %s\n", key, res)
fieldVal.Set(reflect.ValueOf(res))
}
}
}
// update the map with the modified Protocol (cfg.Protocol[account] = Protocol)
val.Field(i).SetMapIndex(reflect.ValueOf(account), reflect.ValueOf(protoCfg))
break
}
}
if mycfg.cv.General.MediaDownloadSize == 0 {
mycfg.cv.General.MediaDownloadSize = 1000000
}
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
logger.Println("Config file changed:", e.Name)
})
return mycfg
}
// detectConfigType detects JSON and YAML formats, defaults to TOML.
func detectConfigType(cfgfile string) string {
fileExt := filepath.Ext(cfgfile)
switch fileExt {
case ".json":
return "json"
case ".yaml", ".yml":
return "yaml"
}
return "toml"
}
// NewConfigFromString instantiates a new configuration based on the specified string.
func NewConfigFromString(rootLogger *logrus.Logger, input []byte) Config {
logger := rootLogger.WithFields(logrus.Fields{"prefix": "config"})
return newConfigFromString(logger, input, "toml")
}
func newConfigFromString(logger *logrus.Entry, input []byte, cfgtype string) *config {
viper.SetConfigType(cfgtype)
viper.SetEnvPrefix("matterbridge")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
viper.AutomaticEnv()
if err := viper.ReadConfig(bytes.NewBuffer(input)); err != nil {
logger.Fatalf("Failed to parse the configuration: %s", err)
}
cfg := &BridgeValues{}
if err := viper.Unmarshal(cfg); err != nil {
logger.Fatalf("Failed to load the configuration: %s", err)
}
return &config{
logger: logger,
v: viper.GetViper(),
cv: cfg,
}
}
func (c *config) BridgeValues() *BridgeValues {
return c.cv
}
func (c *config) Viper() *viper.Viper {
return c.v
}
func (c *config) IsKeySet(key string) bool {
c.RLock()
defer c.RUnlock()
return c.v.IsSet(key)
}
func (c *config) GetBool(key string) (bool, bool) {
c.RLock()
defer c.RUnlock()
return c.v.GetBool(key), c.v.IsSet(key)
}
func (c *config) GetInt(key string) (int, bool) {
c.RLock()
defer c.RUnlock()
return c.v.GetInt(key), c.v.IsSet(key)
}
func (c *config) GetString(key string) (string, bool) {
c.RLock()
defer c.RUnlock()
return c.v.GetString(key), c.v.IsSet(key)
}
func (c *config) GetStringSlice(key string) ([]string, bool) {
c.RLock()
defer c.RUnlock()
return c.v.GetStringSlice(key), c.v.IsSet(key)
}
func (c *config) GetStringSlice2D(key string) ([][]string, bool) {
c.RLock()
defer c.RUnlock()
res, ok := c.v.Get(key).([]interface{})
if !ok {
return nil, false
}
var result [][]string
for _, entry := range res {
result2 := []string{}
for _, entry2 := range entry.([]interface{}) {
result2 = append(result2, entry2.(string))
}
result = append(result, result2)
}
return result, true
}
func GetIconURL(msg *Message, iconURL string) string {
info := strings.Split(msg.Account, ".")
protocol := info[0]
name := info[1]
func GetIconURL(msg *Message, cfg *Protocol) string {
iconURL := cfg.IconURL
iconURL = strings.Replace(iconURL, "{NICK}", msg.Username, -1)
iconURL = strings.Replace(iconURL, "{BRIDGE}", name, -1)
iconURL = strings.Replace(iconURL, "{PROTOCOL}", protocol, -1)
iconURL = strings.Replace(iconURL, "{BRIDGE}", msg.Origin, -1)
iconURL = strings.Replace(iconURL, "{PROTOCOL}", msg.Protocol, -1)
return iconURL
}
type TestConfig struct {
Config
Overrides map[string]interface{}
}
func (c *TestConfig) IsKeySet(key string) bool {
_, ok := c.Overrides[key]
return ok || c.Config.IsKeySet(key)
}
func (c *TestConfig) GetBool(key string) (bool, bool) {
val, ok := c.Overrides[key]
if ok {
return val.(bool), true
}
return c.Config.GetBool(key)
}
func (c *TestConfig) GetInt(key string) (int, bool) {
if val, ok := c.Overrides[key]; ok {
return val.(int), true
}
return c.Config.GetInt(key)
}
func (c *TestConfig) GetString(key string) (string, bool) {
if val, ok := c.Overrides[key]; ok {
return val.(string), true
}
return c.Config.GetString(key)
}
func (c *TestConfig) GetStringSlice(key string) ([]string, bool) {
if val, ok := c.Overrides[key]; ok {
return val.([]string), true
}
return c.Config.GetStringSlice(key)
}
func (c *TestConfig) GetStringSlice2D(key string) ([][]string, bool) {
if val, ok := c.Overrides[key]; ok {
return val.([][]string), true
}
return c.Config.GetStringSlice2D(key)
func GetNick(msg *Message, cfg *Protocol) string {
nick := cfg.RemoteNickFormat
nick = strings.Replace(nick, "{NICK}", msg.Username, -1)
nick = strings.Replace(nick, "{BRIDGE}", msg.Origin, -1)
nick = strings.Replace(nick, "{PROTOCOL}", msg.Protocol, -1)
return nick
}

View File

@ -1,349 +1,153 @@
package bdiscord
import (
"bytes"
"fmt"
"strings"
"sync"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/discord/transmitter"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/matterbridge/discordgo"
log "github.com/Sirupsen/logrus"
"github.com/bwmarrin/discordgo"
"strings"
)
const MessageLength = 1950
type Bdiscord struct {
*bridge.Config
c *discordgo.Session
nick string
userID string
guildID string
channelsMutex sync.RWMutex
channels []*discordgo.Channel
channelInfoMap map[string]*config.ChannelInfo
membersMutex sync.RWMutex
userMemberMap map[string]*discordgo.Member
nickMemberMap map[string]*discordgo.Member
// Webhook specific logic
useAutoWebhooks bool
transmitter *transmitter.Transmitter
type bdiscord struct {
c *discordgo.Session
Config *config.Protocol
Remote chan config.Message
protocol string
origin string
Channels []*discordgo.Channel
Nick string
UseChannelID bool
}
func New(cfg *bridge.Config) bridge.Bridger {
b := &Bdiscord{Config: cfg}
b.userMemberMap = make(map[string]*discordgo.Member)
b.nickMemberMap = make(map[string]*discordgo.Member)
b.channelInfoMap = make(map[string]*config.ChannelInfo)
var flog *log.Entry
var protocol = "discord"
b.useAutoWebhooks = b.GetBool("AutoWebhooks")
if b.useAutoWebhooks {
b.Log.Debug("Using automatic webhooks")
}
func init() {
flog = log.WithFields(log.Fields{"module": protocol})
}
func New(cfg config.Protocol, origin string, c chan config.Message) *bdiscord {
b := &bdiscord{}
b.Config = &cfg
b.Remote = c
b.protocol = protocol
b.origin = origin
return b
}
func (b *Bdiscord) Connect() error {
func (b *bdiscord) Connect() error {
var err error
token := b.GetString("Token")
b.Log.Info("Connecting")
if !strings.HasPrefix(b.GetString("Token"), "Bot ") {
token = "Bot " + b.GetString("Token")
}
// if we have a User token, remove the `Bot` prefix
if strings.HasPrefix(b.GetString("Token"), "User ") {
token = strings.Replace(b.GetString("Token"), "User ", "", -1)
}
b.c, err = discordgo.New(token)
flog.Info("Connecting")
b.c, err = discordgo.New(b.Config.Token)
if err != nil {
flog.Debugf("%#v", err)
return err
}
b.Log.Info("Connection succeeded")
flog.Info("Connection succeeded")
b.c.AddHandler(b.messageCreate)
b.c.AddHandler(b.messageTyping)
b.c.AddHandler(b.memberUpdate)
b.c.AddHandler(b.messageUpdate)
b.c.AddHandler(b.messageDelete)
b.c.AddHandler(b.messageDeleteBulk)
b.c.AddHandler(b.memberAdd)
b.c.AddHandler(b.memberRemove)
// Add privileged intent for guild member tracking. This is needed to track nicks
// for display names and @mention translation
b.c.Identify.Intents = discordgo.MakeIntent(discordgo.IntentsAllWithoutPrivileged |
discordgo.IntentsGuildMembers)
err = b.c.Open()
if err != nil {
flog.Debugf("%#v", err)
return err
}
guilds, err := b.c.UserGuilds(100, "", "")
guilds, err := b.c.UserGuilds()
if err != nil {
flog.Debugf("%#v", err)
return err
}
userinfo, err := b.c.User("@me")
if err != nil {
flog.Debugf("%#v", err)
return err
}
serverName := strings.Replace(b.GetString("Server"), "ID:", "", -1)
b.nick = userinfo.Username
b.userID = userinfo.ID
// Try and find this account's guild, and populate channels
b.channelsMutex.Lock()
b.Nick = userinfo.Username
for _, guild := range guilds {
// Skip, if the server name does not match the visible name or the ID
if guild.Name != serverName && guild.ID != serverName {
continue
}
// Complain about an ambiguous Server setting. Two Discord servers could have the same title!
// For IDs, practically this will never happen. It would only trigger if some server's name is also an ID.
if b.guildID != "" {
return fmt.Errorf("found multiple Discord servers with the same name %#v, expected to see only one", serverName)
}
// Getting this guild's channel could result in a permission error
b.channels, err = b.c.GuildChannels(guild.ID)
if err != nil {
return fmt.Errorf("could not get %#v's channels: %w", b.GetString("Server"), err)
}
b.guildID = guild.ID
}
b.channelsMutex.Unlock()
// If we couldn't find a guild, we print extra debug information and return a nice error
if b.guildID == "" {
err = fmt.Errorf("could not find Discord server %#v", b.GetString("Server"))
b.Log.Error(err.Error())
// Print all of the possible server values
b.Log.Info("Possible server values:")
for _, guild := range guilds {
b.Log.Infof("\t- Server=%#v # by name", guild.Name)
b.Log.Infof("\t- Server=%#v # by ID", guild.ID)
}
// If there are no results, we should say that
if len(guilds) == 0 {
b.Log.Info("\t- (none found)")
}
return err
}
// Legacy note: WebhookURL used to have an actual webhook URL that we would edit,
// but we stopped doing that due to Discord making rate limits more aggressive.
//
// Even older: the same WebhookURL used to be used by every channel, which is usually unexpected.
// This is no longer possible.
if b.GetString("WebhookURL") != "" {
message := "The global WebhookURL setting has been removed. "
message += "You can get similar \"webhook editing\" behaviour by replacing this line with `AutoWebhooks=true`. "
message += "If you rely on the old-OLD (non-editing) behaviour, can move the WebhookURL to specific channel sections."
b.Log.Errorln(message)
return fmt.Errorf("use of removed WebhookURL setting")
}
if b.GetInt("debuglevel") > 0 {
b.Log.Debug("enabling even more discord debug")
b.c.Debug = true
}
// Initialise webhook management
b.transmitter = transmitter.New(b.c, b.guildID, "matterbridge", b.useAutoWebhooks)
b.transmitter.Log = b.Log
var webhookChannelIDs []string
for _, channel := range b.Channels {
channelID := b.getChannelID(channel.Name) // note(qaisjp): this readlocks channelsMutex
// If a WebhookURL was not explicitly provided for this channel,
// there are two options: just a regular bot message (ugly) or this is should be webhook sent
if channel.Options.WebhookURL == "" {
// If it should be webhook sent, we should enforce this via the transmitter
if b.useAutoWebhooks {
webhookChannelIDs = append(webhookChannelIDs, channelID)
if guild.Name == b.Config.Server {
b.Channels, err = b.c.GuildChannels(guild.ID)
if err != nil {
flog.Debugf("%#v", err)
return err
}
continue
}
whID, whToken, ok := b.splitURL(channel.Options.WebhookURL)
if !ok {
return fmt.Errorf("failed to parse WebhookURL %#v for channel %#v", channel.Options.WebhookURL, channel.ID)
}
b.transmitter.AddWebhook(channelID, &discordgo.Webhook{
ID: whID,
Token: whToken,
GuildID: b.guildID,
ChannelID: channelID,
})
}
if b.useAutoWebhooks {
err = b.transmitter.RefreshGuildWebhooks(webhookChannelIDs)
if err != nil {
b.Log.WithError(err).Println("transmitter could not refresh guild webhooks")
return err
}
}
// Obtaining guild members and initializing nickname mapping.
b.membersMutex.Lock()
defer b.membersMutex.Unlock()
members, err := b.c.GuildMembers(b.guildID, "", 1000)
if err != nil {
b.Log.Error("Error obtaining server members: ", err)
return err
}
for _, member := range members {
if member == nil {
b.Log.Warnf("Skipping missing information for a user.")
continue
}
b.userMemberMap[member.User.ID] = member
b.nickMemberMap[member.User.Username] = member
if member.Nick != "" {
b.nickMemberMap[member.Nick] = member
}
}
return nil
}
func (b *Bdiscord) Disconnect() error {
return b.c.Close()
func (b *bdiscord) FullOrigin() string {
return b.protocol + "." + b.origin
}
func (b *Bdiscord) JoinChannel(channel config.ChannelInfo) error {
b.channelsMutex.Lock()
defer b.channelsMutex.Unlock()
b.channelInfoMap[channel.ID] = &channel
func (b *bdiscord) JoinChannel(channel string) error {
idcheck := strings.Split(channel, "ID:")
if len(idcheck) > 1 {
b.UseChannelID = true
}
return nil
}
func (b *Bdiscord) Send(msg config.Message) (string, error) {
b.Log.Debugf("=> Receiving %#v", msg)
func (b *bdiscord) Name() string {
return b.protocol + "." + b.origin
}
func (b *bdiscord) Protocol() string {
return b.protocol
}
func (b *bdiscord) Origin() string {
return b.origin
}
func (b *bdiscord) Send(msg config.Message) error {
flog.Debugf("Receiving %#v", msg)
channelID := b.getChannelID(msg.Channel)
if channelID == "" {
return "", fmt.Errorf("Could not find channelID for %v", msg.Channel)
flog.Errorf("Could not find channelID for %v", msg.Channel)
return nil
}
if msg.Event == config.EventUserTyping {
if b.GetBool("ShowUserTyping") {
err := b.c.ChannelTyping(channelID)
return "", err
}
return "", nil
}
// Make a action /me of the message
if msg.Event == config.EventUserAction {
msg.Text = "_" + msg.Text + "_"
}
// Handle prefix hint for unthreaded messages.
if msg.ParentNotFound() {
msg.ParentID = ""
msg.Text = fmt.Sprintf("[thread]: %s", msg.Text)
}
// Use webhook to send the message
useWebhooks := b.shouldMessageUseWebhooks(&msg)
if useWebhooks && msg.Event != config.EventMsgDelete && msg.ParentID == "" {
return b.handleEventWebhook(&msg, channelID)
}
return b.handleEventBotUser(&msg, channelID)
nick := config.GetNick(&msg, b.Config)
b.c.ChannelMessageSend(channelID, nick+msg.Text)
return nil
}
// handleEventDirect handles events via the bot user
func (b *Bdiscord) handleEventBotUser(msg *config.Message, channelID string) (string, error) {
b.Log.Debugf("Broadcasting using token (API)")
// Delete message
if msg.Event == config.EventMsgDelete {
if msg.ID == "" {
return "", nil
}
err := b.c.ChannelMessageDelete(channelID, msg.ID)
return "", err
func (b *bdiscord) messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
// not relay our own messages
if m.Author.Username == b.Nick {
return
}
// Upload a file if it exists
if msg.Extra != nil {
for _, rmsg := range helper.HandleExtra(msg, b.General) {
rmsg.Text = helper.ClipMessage(rmsg.Text, MessageLength, b.GetString("MessageClipped"))
if _, err := b.c.ChannelMessageSend(channelID, rmsg.Username+rmsg.Text); err != nil {
b.Log.Errorf("Could not send message %#v: %s", rmsg, err)
}
}
// check if we have files to upload (from slack, telegram or mattermost)
if len(msg.Extra["file"]) > 0 {
return b.handleUploadFile(msg, channelID)
if len(m.Attachments) > 0 {
for _, attach := range m.Attachments {
m.Content = m.Content + "\n" + attach.URL
}
}
msg.Text = helper.ClipMessage(msg.Text, MessageLength, b.GetString("MessageClipped"))
msg.Text = b.replaceUserMentions(msg.Text)
// Edit message
if msg.ID != "" {
_, err := b.c.ChannelMessageEdit(channelID, msg.ID, msg.Username+msg.Text)
return msg.ID, err
if m.Content == "" {
return
}
m := discordgo.MessageSend{
Content: msg.Username + msg.Text,
AllowedMentions: b.getAllowedMentions(),
flog.Debugf("Sending message from %s on %s to gateway", m.Author.Username, b.FullOrigin())
channelName := b.getChannelName(m.ChannelID)
if b.UseChannelID {
channelName = "ID:" + m.ChannelID
}
if msg.ParentValid() {
m.Reference = &discordgo.MessageReference{
MessageID: msg.ParentID,
ChannelID: channelID,
GuildID: b.guildID,
}
}
// Post normal message
res, err := b.c.ChannelMessageSendComplex(channelID, &m)
if err != nil {
return "", err
}
return res.ID, nil
b.Remote <- config.Message{Username: m.Author.Username, Text: m.ContentWithMentionsReplaced(), Channel: channelName,
Origin: b.origin, Protocol: b.protocol, FullOrigin: b.FullOrigin(), Avatar: "https://cdn.discordapp.com/avatars/" + m.Author.ID + "/" + m.Author.Avatar + ".jpg"}
}
// handleUploadFile handles native upload of files
func (b *Bdiscord) handleUploadFile(msg *config.Message, channelID string) (string, error) {
var err error
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
file := discordgo.File{
Name: fi.Name,
ContentType: "",
Reader: bytes.NewReader(*fi.Data),
}
m := discordgo.MessageSend{
Content: msg.Username + fi.Comment,
Files: []*discordgo.File{&file},
AllowedMentions: b.getAllowedMentions(),
}
_, err = b.c.ChannelMessageSendComplex(channelID, &m)
if err != nil {
return "", fmt.Errorf("file upload failed: %s", err)
func (b *bdiscord) getChannelID(name string) string {
idcheck := strings.Split(name, "ID:")
if len(idcheck) > 1 {
return idcheck[1]
}
for _, channel := range b.Channels {
if channel.Name == name {
return channel.ID
}
}
return "", nil
return ""
}
func (b *bdiscord) getChannelName(id string) string {
for _, channel := range b.Channels {
if channel.ID == id {
return channel.Name
}
}
return ""
}

View File

@ -1,237 +0,0 @@
package bdiscord
import (
"github.com/42wim/matterbridge/bridge/config"
"github.com/matterbridge/discordgo"
)
func (b *Bdiscord) messageDelete(s *discordgo.Session, m *discordgo.MessageDelete) { //nolint:unparam
rmsg := config.Message{Account: b.Account, ID: m.ID, Event: config.EventMsgDelete, Text: config.EventMsgDelete}
rmsg.Channel = b.getChannelName(m.ChannelID)
b.Log.Debugf("<= Sending message from %s to gateway", b.Account)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}
// TODO(qaisjp): if other bridges support bulk deletions, it could be fanned out centrally
func (b *Bdiscord) messageDeleteBulk(s *discordgo.Session, m *discordgo.MessageDeleteBulk) { //nolint:unparam
for _, msgID := range m.Messages {
rmsg := config.Message{
Account: b.Account,
ID: msgID,
Event: config.EventMsgDelete,
Text: config.EventMsgDelete,
Channel: b.getChannelName(m.ChannelID),
}
b.Log.Debugf("<= Sending message from %s to gateway", b.Account)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}
}
func (b *Bdiscord) messageTyping(s *discordgo.Session, m *discordgo.TypingStart) {
if !b.GetBool("ShowUserTyping") {
return
}
// Ignore our own typing messages
if m.UserID == b.userID {
return
}
rmsg := config.Message{Account: b.Account, Event: config.EventUserTyping}
rmsg.Channel = b.getChannelName(m.ChannelID)
b.Remote <- rmsg
}
func (b *Bdiscord) messageUpdate(s *discordgo.Session, m *discordgo.MessageUpdate) { //nolint:unparam
if b.GetBool("EditDisable") {
return
}
// only when message is actually edited
if m.Message.EditedTimestamp != "" {
b.Log.Debugf("Sending edit message")
m.Content += b.GetString("EditSuffix")
msg := &discordgo.MessageCreate{
Message: m.Message,
}
b.messageCreate(s, msg)
}
}
func (b *Bdiscord) messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { //nolint:unparam
var err error
// not relay our own messages
if m.Author.Username == b.nick {
return
}
// if using webhooks, do not relay if it's ours
if m.Author.Bot && b.transmitter.HasWebhook(m.Author.ID) {
return
}
// add the url of the attachments to content
if len(m.Attachments) > 0 {
for _, attach := range m.Attachments {
m.Content = m.Content + "\n" + attach.URL
}
}
rmsg := config.Message{Account: b.Account, Avatar: "https://cdn.discordapp.com/avatars/" + m.Author.ID + "/" + m.Author.Avatar + ".jpg", UserID: m.Author.ID, ID: m.ID}
if m.Content != "" {
b.Log.Debugf("== Receiving event %#v", m.Message)
m.Message.Content = b.replaceChannelMentions(m.Message.Content)
rmsg.Text, err = m.ContentWithMoreMentionsReplaced(b.c)
if err != nil {
b.Log.Errorf("ContentWithMoreMentionsReplaced failed: %s", err)
rmsg.Text = m.ContentWithMentionsReplaced()
}
}
// set channel name
rmsg.Channel = b.getChannelName(m.ChannelID)
fromWebhook := m.WebhookID != ""
if !fromWebhook && !b.GetBool("UseUserName") {
rmsg.Username = b.getNick(m.Author, m.GuildID)
} else {
rmsg.Username = m.Author.Username
if !fromWebhook && b.GetBool("UseDiscriminator") {
rmsg.Username += "#" + m.Author.Discriminator
}
}
// if we have embedded content add it to text
if b.GetBool("ShowEmbeds") && m.Message.Embeds != nil {
for _, embed := range m.Message.Embeds {
rmsg.Text += handleEmbed(embed)
}
}
// no empty messages
if rmsg.Text == "" {
return
}
// do we have a /me action
var ok bool
rmsg.Text, ok = b.replaceAction(rmsg.Text)
if ok {
rmsg.Event = config.EventUserAction
}
// Replace emotes
rmsg.Text = replaceEmotes(rmsg.Text)
// Add our parent id if it exists, and if it's not referring to a message in another channel
if ref := m.MessageReference; ref != nil && ref.ChannelID == m.ChannelID {
rmsg.ParentID = ref.MessageID
}
b.Log.Debugf("<= Sending message from %s on %s to gateway", m.Author.Username, b.Account)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}
func (b *Bdiscord) memberUpdate(s *discordgo.Session, m *discordgo.GuildMemberUpdate) {
if m.Member == nil {
b.Log.Warnf("Received member update with no member information: %#v", m)
}
b.membersMutex.Lock()
defer b.membersMutex.Unlock()
if currMember, ok := b.userMemberMap[m.Member.User.ID]; ok {
b.Log.Debugf(
"%s: memberupdate: user %s (nick %s) changes nick to %s",
b.Account,
m.Member.User.Username,
b.userMemberMap[m.Member.User.ID].Nick,
m.Member.Nick,
)
delete(b.nickMemberMap, currMember.User.Username)
delete(b.nickMemberMap, currMember.Nick)
delete(b.userMemberMap, m.Member.User.ID)
}
b.userMemberMap[m.Member.User.ID] = m.Member
b.nickMemberMap[m.Member.User.Username] = m.Member
if m.Member.Nick != "" {
b.nickMemberMap[m.Member.Nick] = m.Member
}
}
func (b *Bdiscord) memberAdd(s *discordgo.Session, m *discordgo.GuildMemberAdd) {
if m.Member == nil {
b.Log.Warnf("Received member update with no member information: %#v", m)
return
}
username := m.Member.User.Username
if m.Member.Nick != "" {
username = m.Member.Nick
}
rmsg := config.Message{
Account: b.Account,
Event: config.EventJoinLeave,
Username: "system",
Text: username + " joins",
}
b.Log.Debugf("<= Sending message from %s to gateway", b.Account)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}
func (b *Bdiscord) memberRemove(s *discordgo.Session, m *discordgo.GuildMemberRemove) {
if m.Member == nil {
b.Log.Warnf("Received member update with no member information: %#v", m)
return
}
username := m.Member.User.Username
if m.Member.Nick != "" {
username = m.Member.Nick
}
rmsg := config.Message{
Account: b.Account,
Event: config.EventJoinLeave,
Username: "system",
Text: username + " leaves",
}
b.Log.Debugf("<= Sending message from %s to gateway", b.Account)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}
func handleEmbed(embed *discordgo.MessageEmbed) string {
var t []string
var result string
t = append(t, embed.Title)
t = append(t, embed.Description)
t = append(t, embed.URL)
i := 0
for _, e := range t {
if e == "" {
continue
}
i++
if i == 1 {
result += " embed: " + e
continue
}
result += " - " + e
}
if result != "" {
result += "\n"
}
return result
}

View File

@ -1,58 +0,0 @@
package bdiscord
import (
"testing"
"github.com/matterbridge/discordgo"
"github.com/stretchr/testify/assert"
)
func TestHandleEmbed(t *testing.T) {
testcases := map[string]struct {
embed *discordgo.MessageEmbed
result string
}{
"allempty": {
embed: &discordgo.MessageEmbed{},
result: "",
},
"one": {
embed: &discordgo.MessageEmbed{
Title: "blah",
},
result: " embed: blah\n",
},
"two": {
embed: &discordgo.MessageEmbed{
Title: "blah",
Description: "blah2",
},
result: " embed: blah - blah2\n",
},
"three": {
embed: &discordgo.MessageEmbed{
Title: "blah",
Description: "blah2",
URL: "blah3",
},
result: " embed: blah - blah2 - blah3\n",
},
"twob": {
embed: &discordgo.MessageEmbed{
Description: "blah2",
URL: "blah3",
},
result: " embed: blah2 - blah3\n",
},
"oneb": {
embed: &discordgo.MessageEmbed{
URL: "blah3",
},
result: " embed: blah3\n",
},
}
for name, tc := range testcases {
assert.Equalf(t, tc.result, handleEmbed(tc.embed), "Testcases %s", name)
}
}

View File

@ -1,269 +0,0 @@
package bdiscord
import (
"errors"
"regexp"
"strings"
"unicode"
"github.com/matterbridge/discordgo"
)
func (b *Bdiscord) getAllowedMentions() *discordgo.MessageAllowedMentions {
// If AllowMention is not specified, then allow all mentions (default Discord behavior)
if !b.IsKeySet("AllowMention") {
return nil
}
// Otherwise, allow only the mentions that are specified
allowedMentionTypes := make([]discordgo.AllowedMentionType, 0, 3)
for _, m := range b.GetStringSlice("AllowMention") {
switch m {
case "everyone":
allowedMentionTypes = append(allowedMentionTypes, discordgo.AllowedMentionTypeEveryone)
case "roles":
allowedMentionTypes = append(allowedMentionTypes, discordgo.AllowedMentionTypeRoles)
case "users":
allowedMentionTypes = append(allowedMentionTypes, discordgo.AllowedMentionTypeUsers)
}
}
return &discordgo.MessageAllowedMentions{
Parse: allowedMentionTypes,
}
}
func (b *Bdiscord) getNick(user *discordgo.User, guildID string) string {
b.membersMutex.RLock()
defer b.membersMutex.RUnlock()
if member, ok := b.userMemberMap[user.ID]; ok {
if member.Nick != "" {
// Only return if nick is set.
return member.Nick
}
// Otherwise return username.
return user.Username
}
// If we didn't find nick, search for it.
member, err := b.c.GuildMember(guildID, user.ID)
if err != nil {
b.Log.Warnf("Failed to fetch information for member %#v on guild %#v: %s", user, guildID, err)
return user.Username
} else if member == nil {
b.Log.Warnf("Got no information for member %#v", user)
return user.Username
}
b.userMemberMap[user.ID] = member
b.nickMemberMap[member.User.Username] = member
if member.Nick != "" {
b.nickMemberMap[member.Nick] = member
return member.Nick
}
return user.Username
}
func (b *Bdiscord) getGuildMemberByNick(nick string) (*discordgo.Member, error) {
b.membersMutex.RLock()
defer b.membersMutex.RUnlock()
if member, ok := b.nickMemberMap[nick]; ok {
return member, nil
}
return nil, errors.New("Couldn't find guild member with nick " + nick) // This will most likely get ignored by the caller
}
func (b *Bdiscord) getChannelID(name string) string {
if strings.Contains(name, "/") {
return b.getCategoryChannelID(name)
}
b.channelsMutex.RLock()
defer b.channelsMutex.RUnlock()
idcheck := strings.Split(name, "ID:")
if len(idcheck) > 1 {
return idcheck[1]
}
for _, channel := range b.channels {
if channel.Name == name && channel.Type == discordgo.ChannelTypeGuildText {
return channel.ID
}
}
return ""
}
func (b *Bdiscord) getCategoryChannelID(name string) string {
b.channelsMutex.RLock()
defer b.channelsMutex.RUnlock()
res := strings.Split(name, "/")
// shouldn't happen because function should be only called from getChannelID
if len(res) != 2 {
return ""
}
catName, chanName := res[0], res[1]
for _, channel := range b.channels {
// if we have a parentID, lookup the name of that parent (category)
// and if it matches return it
if channel.Name == chanName && channel.ParentID != "" {
for _, cat := range b.channels {
if cat.ID == channel.ParentID && cat.Name == catName {
return channel.ID
}
}
}
}
return ""
}
func (b *Bdiscord) getChannelName(id string) string {
b.channelsMutex.RLock()
defer b.channelsMutex.RUnlock()
for _, c := range b.channelInfoMap {
if c.Name == "ID:"+id {
// if we have ID: specified in our gateway configuration return this
return c.Name
}
}
for _, channel := range b.channels {
if channel.ID == id {
return b.getCategoryChannelName(channel.Name, channel.ParentID)
}
}
return ""
}
func (b *Bdiscord) getCategoryChannelName(name, parentID string) string {
var usesCat bool
// do we have a category configuration in the channel config
for _, c := range b.channelInfoMap {
if strings.Contains(c.Name, "/") {
usesCat = true
break
}
}
// configuration without category, return the normal channel name
if !usesCat {
return name
}
// create a category/channel response
for _, c := range b.channels {
if c.ID == parentID {
name = c.Name + "/" + name
}
}
return name
}
var (
// See https://discordapp.com/developers/docs/reference#message-formatting.
channelMentionRE = regexp.MustCompile("<#[0-9]+>")
userMentionRE = regexp.MustCompile("@[^@\n]{1,32}")
emoteRE = regexp.MustCompile(`<a?(:\w+:)\d+>`)
)
func (b *Bdiscord) replaceChannelMentions(text string) string {
replaceChannelMentionFunc := func(match string) string {
channelID := match[2 : len(match)-1]
channelName := b.getChannelName(channelID)
// If we don't have the channel refresh our list.
if channelName == "" {
var err error
b.channels, err = b.c.GuildChannels(b.guildID)
if err != nil {
return "#unknownchannel"
}
channelName = b.getChannelName(channelID)
}
return "#" + channelName
}
return channelMentionRE.ReplaceAllStringFunc(text, replaceChannelMentionFunc)
}
func (b *Bdiscord) replaceUserMentions(text string) string {
replaceUserMentionFunc := func(match string) string {
var (
err error
member *discordgo.Member
username string
)
usernames := enumerateUsernames(match[1:])
for _, username = range usernames {
b.Log.Debugf("Testing mention: '%s'", username)
member, err = b.getGuildMemberByNick(username)
if err == nil {
break
}
}
if member == nil {
return match
}
return strings.Replace(match, "@"+username, member.User.Mention(), 1)
}
return userMentionRE.ReplaceAllStringFunc(text, replaceUserMentionFunc)
}
func replaceEmotes(text string) string {
return emoteRE.ReplaceAllString(text, "$1")
}
func (b *Bdiscord) replaceAction(text string) (string, bool) {
length := len(text)
if length > 1 && text[0] == '_' && text[length-1] == '_' {
return text[1 : length-1], true
}
return text, false
}
// splitURL splits a webhookURL and returns the ID and token.
func (b *Bdiscord) splitURL(url string) (string, string, bool) {
const (
expectedWebhookSplitCount = 7
webhookIdxID = 5
webhookIdxToken = 6
)
webhookURLSplit := strings.Split(url, "/")
if len(webhookURLSplit) != expectedWebhookSplitCount {
return "", "", false
}
return webhookURLSplit[webhookIdxID], webhookURLSplit[webhookIdxToken], true
}
func enumerateUsernames(s string) []string {
onlySpace := true
for _, r := range s {
if !unicode.IsSpace(r) {
onlySpace = false
break
}
}
if onlySpace {
return nil
}
var username, endSpace string
var usernames []string
skippingSpace := true
for _, r := range s {
if unicode.IsSpace(r) {
if !skippingSpace {
usernames = append(usernames, username)
skippingSpace = true
}
endSpace += string(r)
username += string(r)
} else {
endSpace = ""
username += string(r)
skippingSpace = false
}
}
if endSpace == "" {
usernames = append(usernames, username)
}
return usernames
}

View File

@ -1,46 +0,0 @@
package bdiscord
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestEnumerateUsernames(t *testing.T) {
testcases := map[string]struct {
match string
expectedUsernames []string
}{
"only space": {
match: " \t\n \t",
expectedUsernames: nil,
},
"single word": {
match: "veni",
expectedUsernames: []string{"veni"},
},
"single word with preceeding space": {
match: " vidi",
expectedUsernames: []string{" vidi"},
},
"single word with suffixed space": {
match: "vici ",
expectedUsernames: []string{"vici"},
},
"multi-word with varying whitespace": {
match: "just me and\tmy friends \t",
expectedUsernames: []string{
"just",
"just me",
"just me and",
"just me and\tmy",
"just me and\tmy friends",
},
},
}
for testname, testcase := range testcases {
foundUsernames := enumerateUsernames(testcase.match)
assert.Equalf(t, testcase.expectedUsernames, foundUsernames, "Should have found the expected usernames for testcase %s", testname)
}
}

View File

@ -1,257 +0,0 @@
// Package transmitter provides functionality for transmitting
// arbitrary webhook messages to Discord.
//
// The package provides the following functionality:
//
// - Creating new webhooks, whenever necessary
// - Loading webhooks that we have previously created
// - Sending new messages
// - Editing messages, via message ID
// - Deleting messages, via message ID
//
// The package has been designed for matterbridge, but with other
// Go bots in mind. The public API should be matterbridge-agnostic.
package transmitter
import (
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/matterbridge/discordgo"
log "github.com/sirupsen/logrus"
)
// A Transmitter represents a message manager for a single guild.
type Transmitter struct {
session *discordgo.Session
guild string
title string
autoCreate bool
// channelWebhooks maps from a channel ID to a webhook instance
channelWebhooks map[string]*discordgo.Webhook
mutex sync.RWMutex
Log *log.Entry
}
// ErrWebhookNotFound is returned when a valid webhook for this channel/message combination does not exist
var ErrWebhookNotFound = errors.New("webhook for this channel and message does not exist")
// ErrPermissionDenied is returned if the bot does not have permission to manage webhooks.
//
// Bots can be granted a guild-wide permission and channel-specific permissions to manage webhooks.
// Despite potentially having guild-wide permission, channel specific overrides could deny a bot's permission to manage webhooks.
var ErrPermissionDenied = errors.New("missing 'Manage Webhooks' permission")
// New returns a new Transmitter given a Discord session, guild ID, and title.
func New(session *discordgo.Session, guild string, title string, autoCreate bool) *Transmitter {
return &Transmitter{
session: session,
guild: guild,
title: title,
autoCreate: autoCreate,
channelWebhooks: make(map[string]*discordgo.Webhook),
Log: log.NewEntry(log.StandardLogger()),
}
}
// Send transmits a message to the given channel with the provided webhook data, and waits until Discord responds with message data.
func (t *Transmitter) Send(channelID string, params *discordgo.WebhookParams) (*discordgo.Message, error) {
wh, err := t.getOrCreateWebhook(channelID)
if err != nil {
return nil, err
}
msg, err := t.session.WebhookExecute(wh.ID, wh.Token, true, params)
if err != nil {
return nil, fmt.Errorf("execute failed: %w", err)
}
return msg, nil
}
// Edit will edit a message in a channel, if possible.
func (t *Transmitter) Edit(channelID string, messageID string, params *discordgo.WebhookParams) error {
wh := t.getWebhook(channelID)
if wh == nil {
return ErrWebhookNotFound
}
uri := discordgo.EndpointWebhookToken(wh.ID, wh.Token) + "/messages/" + messageID
_, err := t.session.RequestWithBucketID("PATCH", uri, params, discordgo.EndpointWebhookToken("", ""))
if err != nil {
return err
}
return nil
}
// HasWebhook checks whether the transmitter is using a particular webhook.
func (t *Transmitter) HasWebhook(id string) bool {
t.mutex.RLock()
defer t.mutex.RUnlock()
for _, wh := range t.channelWebhooks {
if wh.ID == id {
return true
}
}
return false
}
// AddWebhook allows you to register a channel's webhook with the transmitter.
func (t *Transmitter) AddWebhook(channelID string, webhook *discordgo.Webhook) bool {
t.Log.Debugf("Manually added webhook %#v to channel %#v", webhook.ID, channelID)
t.mutex.Lock()
defer t.mutex.Unlock()
_, replaced := t.channelWebhooks[channelID]
t.channelWebhooks[channelID] = webhook
return replaced
}
// RefreshGuildWebhooks loads "relevant" webhooks into the transmitter, with careful permission handling.
//
// Notes:
//
// - A webhook is "relevant" if it was created by this bot -- the ApplicationID should match the bot's ID.
// - The term "having permission" means having the "Manage Webhooks" permission. See ErrPermissionDenied for more information.
// - This function is additive and will not unload previously loaded webhooks.
// - A nil channelIDs slice is treated the same as an empty one.
//
// If the bot has guild-wide permission:
//
// 1. it will load any "relevant" webhooks from the entire guild
// 2. the given slice is ignored
//
// If the bot does not have guild-wide permission:
//
// 1. it will load any "relevant" webhooks in each channel
// 2. a single error will be returned if any error occurs (incl. if there is no permission for any of these channels)
//
// If any channel has more than one "relevant" webhook, it will randomly pick one.
func (t *Transmitter) RefreshGuildWebhooks(channelIDs []string) error {
t.Log.Debugln("Refreshing guild webhooks")
botID, err := getDiscordUserID(t.session)
if err != nil {
return fmt.Errorf("could not get current user: %w", err)
}
// Get all existing webhooks
hooks, err := t.session.GuildWebhooks(t.guild)
if err != nil {
switch {
case isDiscordPermissionError(err):
// We fallback on manually fetching hooks from individual channels
// if we don't have the "Manage Webhooks" permission globally.
// We can only do this if we were provided channelIDs, though.
if len(channelIDs) == 0 {
return ErrPermissionDenied
}
t.Log.Debugln("Missing global 'Manage Webhooks' permission, falling back on per-channel permission")
return t.fetchChannelsHooks(channelIDs, botID)
default:
return fmt.Errorf("could not get webhooks: %w", err)
}
}
t.Log.Debugln("Refreshing guild webhooks using global permission")
t.assignHooksByAppID(hooks, botID, false)
return nil
}
// createWebhook creates a webhook for a specific channel.
func (t *Transmitter) createWebhook(channel string) (*discordgo.Webhook, error) {
t.mutex.Lock()
defer t.mutex.Unlock()
wh, err := t.session.WebhookCreate(channel, t.title+time.Now().Format(" 3:04:05PM"), "")
if err != nil {
return nil, err
}
t.channelWebhooks[channel] = wh
return wh, nil
}
func (t *Transmitter) getWebhook(channel string) *discordgo.Webhook {
t.mutex.RLock()
defer t.mutex.RUnlock()
return t.channelWebhooks[channel]
}
func (t *Transmitter) getOrCreateWebhook(channelID string) (*discordgo.Webhook, error) {
// If we have a webhook for this channel, immediately return it
wh := t.getWebhook(channelID)
if wh != nil {
return wh, nil
}
// Early exit if we don't want to automatically create one
if !t.autoCreate {
return nil, ErrWebhookNotFound
}
t.Log.Infof("Creating a webhook for %s\n", channelID)
wh, err := t.createWebhook(channelID)
if err != nil {
return nil, fmt.Errorf("could not create webhook: %w", err)
}
return wh, nil
}
// fetchChannelsHooks fetches hooks for the given channelIDs and calls assignHooksByAppID for each channel's hooks
func (t *Transmitter) fetchChannelsHooks(channelIDs []string, botID string) error {
// For each channel, search for relevant hooks
var failedHooks []string
for _, channelID := range channelIDs {
hooks, err := t.session.ChannelWebhooks(channelID)
if err != nil {
failedHooks = append(failedHooks, "\n- "+channelID+": "+err.Error())
continue
}
t.assignHooksByAppID(hooks, botID, true)
}
// Compose an error if any hooks failed
if len(failedHooks) > 0 {
return errors.New("failed to fetch hooks:" + strings.Join(failedHooks, ""))
}
return nil
}
func (t *Transmitter) assignHooksByAppID(hooks []*discordgo.Webhook, appID string, channelTargeted bool) {
logLine := "Picking up webhook"
if channelTargeted {
logLine += " (channel targeted)"
}
t.mutex.Lock()
defer t.mutex.Unlock()
for _, wh := range hooks {
if wh.ApplicationID != appID {
continue
}
t.channelWebhooks[wh.ChannelID] = wh
t.Log.WithFields(log.Fields{
"id": wh.ID,
"name": wh.Name,
"channel": wh.ChannelID,
}).Println(logLine)
}
}

View File

@ -1,32 +0,0 @@
package transmitter
import (
"github.com/matterbridge/discordgo"
)
// isDiscordPermissionError returns false for nil, and true if a Discord RESTError with code discordgo.ErrorCodeMissionPermissions
func isDiscordPermissionError(err error) bool {
if err == nil {
return false
}
restErr, ok := err.(*discordgo.RESTError)
if !ok {
return false
}
return restErr.Message != nil && restErr.Message.Code == discordgo.ErrCodeMissingPermissions
}
// getDiscordUserID gets own user ID from state, and fallback on API request
func getDiscordUserID(session *discordgo.Session) (string, error) {
if user := session.State.User; user != nil {
return user.ID, nil
}
user, err := session.User("@me")
if err != nil {
return "", err
}
return user.ID, nil
}

View File

@ -1,148 +0,0 @@
package bdiscord
import (
"bytes"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/matterbridge/discordgo"
)
// shouldMessageUseWebhooks checks if have a channel specific webhook, if we're not using auto webhooks
func (b *Bdiscord) shouldMessageUseWebhooks(msg *config.Message) bool {
if b.useAutoWebhooks {
return true
}
b.channelsMutex.RLock()
defer b.channelsMutex.RUnlock()
if ci, ok := b.channelInfoMap[msg.Channel+b.Account]; ok {
if ci.Options.WebhookURL != "" {
return true
}
}
return false
}
// maybeGetLocalAvatar checks if UseLocalAvatar contains the message's
// account or protocol, and if so, returns the Discord avatar (if exists)
func (b *Bdiscord) maybeGetLocalAvatar(msg *config.Message) string {
for _, val := range b.GetStringSlice("UseLocalAvatar") {
if msg.Protocol != val && msg.Account != val {
continue
}
member, err := b.getGuildMemberByNick(msg.Username)
if err != nil {
return ""
}
return member.User.AvatarURL("")
}
return ""
}
// webhookSend send one or more message via webhook, taking care of file
// uploads (from slack, telegram or mattermost).
// Returns messageID and error.
func (b *Bdiscord) webhookSend(msg *config.Message, channelID string) (*discordgo.Message, error) {
var (
res *discordgo.Message
err error
)
// If avatar is unset, mutate the message to include the local avatar (but only if settings say we should do this)
if msg.Avatar == "" {
msg.Avatar = b.maybeGetLocalAvatar(msg)
}
// WebhookParams can have either `Content` or `File`.
// We can't send empty messages.
if msg.Text != "" {
res, err = b.transmitter.Send(
channelID,
&discordgo.WebhookParams{
Content: msg.Text,
Username: msg.Username,
AvatarURL: msg.Avatar,
AllowedMentions: b.getAllowedMentions(),
},
)
if err != nil {
b.Log.Errorf("Could not send text (%s) for message %#v: %s", msg.Text, msg, err)
}
}
if msg.Extra != nil {
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
file := discordgo.File{
Name: fi.Name,
ContentType: "",
Reader: bytes.NewReader(*fi.Data),
}
content := fi.Comment
_, e2 := b.transmitter.Send(
channelID,
&discordgo.WebhookParams{
Username: msg.Username,
AvatarURL: msg.Avatar,
File: &file,
Content: content,
AllowedMentions: b.getAllowedMentions(),
},
)
if e2 != nil {
b.Log.Errorf("Could not send file %#v for message %#v: %s", file, msg, e2)
}
}
}
return res, err
}
func (b *Bdiscord) handleEventWebhook(msg *config.Message, channelID string) (string, error) {
// skip events
if msg.Event != "" && msg.Event != config.EventUserAction && msg.Event != config.EventJoinLeave && msg.Event != config.EventTopicChange {
return "", nil
}
// skip empty messages
if msg.Text == "" && (msg.Extra == nil || len(msg.Extra["file"]) == 0) {
b.Log.Debugf("Skipping empty message %#v", msg)
return "", nil
}
msg.Text = helper.ClipMessage(msg.Text, MessageLength, b.GetString("MessageClipped"))
msg.Text = b.replaceUserMentions(msg.Text)
// discord username must be [0..32] max
if len(msg.Username) > 32 {
msg.Username = msg.Username[0:32]
}
if msg.ID != "" {
b.Log.Debugf("Editing webhook message")
err := b.transmitter.Edit(channelID, msg.ID, &discordgo.WebhookParams{
Content: msg.Text,
Username: msg.Username,
AllowedMentions: b.getAllowedMentions(),
})
if err == nil {
return msg.ID, nil
}
b.Log.Errorf("Could not edit webhook message: %s", err)
}
b.Log.Debugf("Processing webhook sending for message %#v", msg)
discordMsg, err := b.webhookSend(msg, channelID)
if err != nil {
b.Log.Errorf("Could not broadcast via webhook for message %#v: %s", msg, err)
return "", err
}
if discordMsg == nil {
return "", nil
}
return discordMsg.ID, nil
}

View File

@ -1,58 +1,62 @@
package bgitter
import (
"fmt"
"strings"
"github.com/42wim/go-gitter"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
log "github.com/Sirupsen/logrus"
"strings"
)
type Bgitter struct {
c *gitter.Gitter
User *gitter.User
Users []gitter.User
Rooms []gitter.Room
*bridge.Config
c *gitter.Gitter
Config *config.Protocol
Remote chan config.Message
protocol string
origin string
Users []gitter.User
Rooms []gitter.Room
}
func New(cfg *bridge.Config) bridge.Bridger {
return &Bgitter{Config: cfg}
var flog *log.Entry
var protocol = "gitter"
func init() {
flog = log.WithFields(log.Fields{"module": protocol})
}
func New(cfg config.Protocol, origin string, c chan config.Message) *Bgitter {
b := &Bgitter{}
b.Config = &cfg
b.Remote = c
b.protocol = protocol
b.origin = origin
return b
}
func (b *Bgitter) Connect() error {
var err error
b.Log.Info("Connecting")
b.c = gitter.New(b.GetString("Token"))
b.User, err = b.c.GetUser()
flog.Info("Connecting")
b.c = gitter.New(b.Config.Token)
_, err = b.c.GetUser()
if err != nil {
flog.Debugf("%#v", err)
return err
}
b.Rooms, err = b.c.GetRooms()
if err != nil {
return err
}
b.Log.Info("Connection succeeded")
flog.Info("Connection succeeded")
b.Rooms, _ = b.c.GetRooms()
return nil
}
func (b *Bgitter) Disconnect() error {
return nil
func (b *Bgitter) FullOrigin() string {
return b.protocol + "." + b.origin
}
func (b *Bgitter) JoinChannel(channel config.ChannelInfo) error {
roomID, err := b.c.GetRoomId(channel.Name)
if err != nil {
return fmt.Errorf("Could not find roomID for %v. Please create the room on gitter.im", channel.Name)
func (b *Bgitter) JoinChannel(channel string) error {
room := channel
roomID := b.getRoomID(room)
if roomID == "" {
return nil
}
room, err := b.c.GetRoom(roomID)
if err != nil {
return err
}
b.Rooms = append(b.Rooms, *room)
user, err := b.c.GetUser()
if err != nil {
return err
@ -67,77 +71,46 @@ func (b *Bgitter) JoinChannel(channel config.ChannelInfo) error {
go b.c.Listen(stream)
go func(stream *gitter.Stream, room string) {
for event := range stream.Event {
for {
event := <-stream.Event
switch ev := event.Data.(type) {
case *gitter.MessageReceived:
// ignore message sent from ourselves
if ev.Message.From.ID != b.User.ID {
b.Log.Debugf("<= Sending message from %s on %s to gateway", ev.Message.From.Username, b.Account)
rmsg := config.Message{Username: ev.Message.From.Username, Text: ev.Message.Text, Channel: room,
Account: b.Account, Avatar: b.getAvatar(ev.Message.From.Username), UserID: ev.Message.From.ID,
ID: ev.Message.ID}
if strings.HasPrefix(ev.Message.Text, "@"+ev.Message.From.Username) {
rmsg.Event = config.EventUserAction
rmsg.Text = strings.Replace(rmsg.Text, "@"+ev.Message.From.Username+" ", "", -1)
}
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
// check for ZWSP to see if it's not an echo
if !strings.HasSuffix(ev.Message.Text, "") {
flog.Debugf("Sending message from %s on %s to gateway", ev.Message.From.Username, b.FullOrigin())
b.Remote <- config.Message{Username: ev.Message.From.Username, Text: ev.Message.Text, Channel: room,
Origin: b.origin, Protocol: b.protocol, FullOrigin: b.FullOrigin(), Avatar: b.getAvatar(ev.Message.From.Username)}
}
case *gitter.GitterConnectionClosed:
b.Log.Errorf("connection with gitter closed for room %s", room)
flog.Errorf("connection with gitter closed for room %s", room)
}
}
}(stream, room.URI)
}(stream, room)
return nil
}
func (b *Bgitter) Send(msg config.Message) (string, error) {
b.Log.Debugf("=> Receiving %#v", msg)
func (b *Bgitter) Name() string {
return b.protocol + "." + b.origin
}
func (b *Bgitter) Protocol() string {
return b.protocol
}
func (b *Bgitter) Origin() string {
return b.origin
}
func (b *Bgitter) Send(msg config.Message) error {
flog.Debugf("Receiving %#v", msg)
roomID := b.getRoomID(msg.Channel)
if roomID == "" {
b.Log.Errorf("Could not find roomID for %v", msg.Channel)
return "", nil
flog.Errorf("Could not find roomID for %v", msg.Channel)
return nil
}
// Delete message
if msg.Event == config.EventMsgDelete {
if msg.ID == "" {
return "", nil
}
// gitter has no delete message api so we edit message to ""
_, err := b.c.UpdateMessage(roomID, msg.ID, "")
if err != nil {
return "", err
}
return "", nil
}
// Upload a file (in gitter case send the upload URL because gitter has no native upload support)
if msg.Extra != nil {
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
b.c.SendMessage(roomID, rmsg.Username+rmsg.Text)
}
if len(msg.Extra["file"]) > 0 {
return b.handleUploadFile(&msg, roomID)
}
}
// Edit message
if msg.ID != "" {
b.Log.Debugf("updating message with id %s", msg.ID)
_, err := b.c.UpdateMessage(roomID, msg.ID, msg.Username+msg.Text)
if err != nil {
return "", err
}
return "", nil
}
// Post normal message
resp, err := b.c.SendMessage(roomID, msg.Username+msg.Text)
if err != nil {
return "", err
}
return resp.ID, nil
nick := config.GetNick(&msg, b.Config)
// add ZWSP because gitter echoes our own messages
return b.c.SendMessage(roomID, nick+msg.Text+" ")
}
func (b *Bgitter) getRoomID(channel string) string {
@ -160,23 +133,3 @@ func (b *Bgitter) getAvatar(user string) string {
}
return avatar
}
func (b *Bgitter) handleUploadFile(msg *config.Message, roomID string) (string, error) {
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
if fi.Comment != "" {
msg.Text += fi.Comment + ": "
}
if fi.URL != "" {
msg.Text = fi.URL
if fi.Comment != "" {
msg.Text = fi.Comment + ": " + fi.URL
}
}
_, err := b.c.SendMessage(roomID, msg.Username+msg.Text)
if err != nil {
return "", err
}
}
return "", nil
}

View File

@ -1,252 +0,0 @@
package harmony
import (
"fmt"
"log"
"strconv"
"strings"
"time"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/harmony-development/shibshib"
chatv1 "github.com/harmony-development/shibshib/gen/chat/v1"
typesv1 "github.com/harmony-development/shibshib/gen/harmonytypes/v1"
profilev1 "github.com/harmony-development/shibshib/gen/profile/v1"
)
type cachedProfile struct {
data *profilev1.GetProfileResponse
lastUpdated time.Time
}
type Bharmony struct {
*bridge.Config
c *shibshib.Client
profileCache map[uint64]cachedProfile
}
func uToStr(in uint64) string {
return strconv.FormatUint(in, 10)
}
func strToU(in string) (uint64, error) {
return strconv.ParseUint(in, 10, 64)
}
func New(cfg *bridge.Config) bridge.Bridger {
b := &Bharmony{
Config: cfg,
profileCache: map[uint64]cachedProfile{},
}
return b
}
func (b *Bharmony) getProfile(u uint64) (*profilev1.GetProfileResponse, error) {
if v, ok := b.profileCache[u]; ok && time.Since(v.lastUpdated) < time.Minute*10 {
return v.data, nil
}
resp, err := b.c.ProfileKit.GetProfile(&profilev1.GetProfileRequest{
UserId: u,
})
if err != nil {
if v, ok := b.profileCache[u]; ok {
return v.data, nil
}
return nil, err
}
b.profileCache[u] = cachedProfile{
data: resp,
lastUpdated: time.Now(),
}
return resp, nil
}
func (b *Bharmony) avatarFor(m *chatv1.Message) string {
if m.Overrides != nil {
return m.Overrides.GetAvatar()
}
profi, err := b.getProfile(m.AuthorId)
if err != nil {
return ""
}
return b.c.TransformHMCURL(profi.Profile.GetUserAvatar())
}
func (b *Bharmony) usernameFor(m *chatv1.Message) string {
if m.Overrides != nil {
return m.Overrides.GetUsername()
}
profi, err := b.getProfile(m.AuthorId)
if err != nil {
return ""
}
return profi.Profile.UserName
}
func (b *Bharmony) toMessage(msg *shibshib.LocatedMessage) config.Message {
message := config.Message{}
message.Account = b.Account
message.UserID = uToStr(msg.Message.AuthorId)
message.Avatar = b.avatarFor(msg.Message)
message.Username = b.usernameFor(msg.Message)
message.Channel = uToStr(msg.ChannelID)
message.ID = uToStr(msg.MessageId)
switch content := msg.Message.Content.Content.(type) {
case *chatv1.Content_EmbedMessage:
message.Text = "Embed"
case *chatv1.Content_AttachmentMessage:
var s strings.Builder
for idx, attach := range content.AttachmentMessage.Files {
s.WriteString(b.c.TransformHMCURL(attach.Id))
if idx < len(content.AttachmentMessage.Files)-1 {
s.WriteString(", ")
}
}
message.Text = s.String()
case *chatv1.Content_PhotoMessage:
var s strings.Builder
for idx, attach := range content.PhotoMessage.GetPhotos() {
s.WriteString(attach.GetCaption().GetText())
s.WriteString("\n")
s.WriteString(b.c.TransformHMCURL(attach.GetHmc()))
if idx < len(content.PhotoMessage.GetPhotos())-1 {
s.WriteString("\n\n")
}
}
message.Text = s.String()
case *chatv1.Content_TextMessage:
message.Text = content.TextMessage.Content.Text
}
return message
}
func (b *Bharmony) outputMessages() {
for {
msg := <-b.c.EventsStream()
if msg.Message.AuthorId == b.c.UserID {
continue
}
b.Remote <- b.toMessage(msg)
}
}
func (b *Bharmony) GetUint64(conf string) uint64 {
num, err := strToU(b.GetString(conf))
if err != nil {
log.Fatal(err)
}
return num
}
func (b *Bharmony) Connect() (err error) {
b.c, err = shibshib.NewClient(b.GetString("Homeserver"), b.GetString("Token"), b.GetUint64("UserID"))
if err != nil {
return
}
b.c.SubscribeToGuild(b.GetUint64("Community"))
go b.outputMessages()
return nil
}
func (b *Bharmony) send(msg config.Message) (id string, err error) {
msgChan, err := strToU(msg.Channel)
if err != nil {
return
}
retID, err := b.c.ChatKit.SendMessage(&chatv1.SendMessageRequest{
GuildId: b.GetUint64("Community"),
ChannelId: msgChan,
Content: &chatv1.Content{
Content: &chatv1.Content_TextMessage{
TextMessage: &chatv1.Content_TextContent{
Content: &chatv1.FormattedText{
Text: msg.Text,
},
},
},
},
Overrides: &chatv1.Overrides{
Username: &msg.Username,
Avatar: &msg.Avatar,
Reason: &chatv1.Overrides_Bridge{Bridge: &typesv1.Empty{}},
},
InReplyTo: nil,
EchoId: nil,
Metadata: nil,
})
if err != nil {
err = fmt.Errorf("send: error sending message: %w", err)
log.Println(err.Error())
}
return uToStr(retID.MessageId), err
}
func (b *Bharmony) delete(msg config.Message) (id string, err error) {
msgChan, err := strToU(msg.Channel)
if err != nil {
return "", err
}
msgID, err := strToU(msg.ID)
if err != nil {
return "", err
}
_, err = b.c.ChatKit.DeleteMessage(&chatv1.DeleteMessageRequest{
GuildId: b.GetUint64("Community"),
ChannelId: msgChan,
MessageId: msgID,
})
return "", err
}
func (b *Bharmony) typing(msg config.Message) (id string, err error) {
msgChan, err := strToU(msg.Channel)
if err != nil {
return "", err
}
_, err = b.c.ChatKit.Typing(&chatv1.TypingRequest{
GuildId: b.GetUint64("Community"),
ChannelId: msgChan,
})
return "", err
}
func (b *Bharmony) Send(msg config.Message) (id string, err error) {
switch msg.Event {
case "":
return b.send(msg)
case config.EventMsgDelete:
return b.delete(msg)
case config.EventUserTyping:
return b.typing(msg)
default:
return "", nil
}
}
func (b *Bharmony) JoinChannel(channel config.ChannelInfo) error {
return nil
}
func (b *Bharmony) Disconnect() error {
return nil
}

View File

@ -1,238 +0,0 @@
package helper
import (
"bytes"
"fmt"
"image/png"
"io"
"net/http"
"regexp"
"strings"
"time"
"unicode/utf8"
"golang.org/x/image/webp"
"github.com/42wim/matterbridge/bridge/config"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"github.com/sirupsen/logrus"
)
// DownloadFile downloads the given non-authenticated URL.
func DownloadFile(url string) (*[]byte, error) {
return DownloadFileAuth(url, "")
}
// DownloadFileAuth downloads the given URL using the specified authentication token.
func DownloadFileAuth(url string, auth string) (*[]byte, error) {
var buf bytes.Buffer
client := &http.Client{
Timeout: time.Second * 5,
}
req, err := http.NewRequest("GET", url, nil)
if auth != "" {
req.Header.Add("Authorization", auth)
}
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
io.Copy(&buf, resp.Body)
data := buf.Bytes()
return &data, nil
}
// DownloadFileAuthRocket downloads the given URL using the specified Rocket user ID and authentication token.
func DownloadFileAuthRocket(url, token, userID string) (*[]byte, error) {
var buf bytes.Buffer
client := &http.Client{
Timeout: time.Second * 5,
}
req, err := http.NewRequest("GET", url, nil)
req.Header.Add("X-Auth-Token", token)
req.Header.Add("X-User-Id", userID)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
_, err = io.Copy(&buf, resp.Body)
data := buf.Bytes()
return &data, err
}
// GetSubLines splits messages in newline-delimited lines. If maxLineLength is
// specified as non-zero GetSubLines will also clip long lines to the maximum
// length and insert a warning marker that the line was clipped.
//
// TODO: The current implementation has the inconvenient that it disregards
// word boundaries when splitting but this is hard to solve without potentially
// breaking formatting and other stylistic effects.
func GetSubLines(message string, maxLineLength int, clippingMessage string) []string {
if clippingMessage == "" {
clippingMessage = " <clipped message>"
}
var lines []string
for _, line := range strings.Split(strings.TrimSpace(message), "\n") {
if maxLineLength == 0 || len([]byte(line)) <= maxLineLength {
lines = append(lines, line)
continue
}
// !!! WARNING !!!
// Before touching the splitting logic below please ensure that you PROPERLY
// understand how strings, runes and range loops over strings work in Go.
// A good place to start is to read https://blog.golang.org/strings. :-)
var splitStart int
var startOfPreviousRune int
for i := range line {
if i-splitStart > maxLineLength-len([]byte(clippingMessage)) {
lines = append(lines, line[splitStart:startOfPreviousRune]+clippingMessage)
splitStart = startOfPreviousRune
}
startOfPreviousRune = i
}
// This last append is safe to do without looking at the remaining byte-length
// as we assume that the byte-length of the last rune will never exceed that of
// the byte-length of the clipping message.
lines = append(lines, line[splitStart:])
}
return lines
}
// HandleExtra manages the supplementary details stored inside a message's 'Extra' field map.
func HandleExtra(msg *config.Message, general *config.Protocol) []config.Message {
extra := msg.Extra
rmsg := []config.Message{}
for _, f := range extra[config.EventFileFailureSize] {
fi := f.(config.FileInfo)
text := fmt.Sprintf("file %s too big to download (%#v > allowed size: %#v)", fi.Name, fi.Size, general.MediaDownloadSize)
rmsg = append(rmsg, config.Message{
Text: text,
Username: "<system> ",
Channel: msg.Channel,
Account: msg.Account,
})
}
return rmsg
}
// GetAvatar constructs a URL for a given user-avatar if it is available in the cache.
func GetAvatar(av map[string]string, userid string, general *config.Protocol) string {
if sha, ok := av[userid]; ok {
return general.MediaServerDownload + "/" + sha + "/" + userid + ".png"
}
return ""
}
// HandleDownloadSize checks a specified filename against the configured download blacklist
// and checks a specified file-size against the configure limit.
func HandleDownloadSize(logger *logrus.Entry, msg *config.Message, name string, size int64, general *config.Protocol) error {
// check blacklist here
for _, entry := range general.MediaDownloadBlackList {
if entry != "" {
re, err := regexp.Compile(entry)
if err != nil {
logger.Errorf("incorrect regexp %s for %s", entry, msg.Account)
continue
}
if re.MatchString(name) {
return fmt.Errorf("Matching blacklist %s. Not downloading %s", entry, name)
}
}
}
logger.Debugf("Trying to download %#v with size %#v", name, size)
if int(size) > general.MediaDownloadSize {
msg.Event = config.EventFileFailureSize
msg.Extra[msg.Event] = append(msg.Extra[msg.Event], config.FileInfo{
Name: name,
Comment: msg.Text,
Size: size,
})
return fmt.Errorf("File %#v to large to download (%#v). MediaDownloadSize is %#v", name, size, general.MediaDownloadSize)
}
return nil
}
// HandleDownloadData adds the data for a remote file into a Matterbridge gateway message.
func HandleDownloadData(logger *logrus.Entry, msg *config.Message, name, comment, url string, data *[]byte, general *config.Protocol) {
var avatar bool
logger.Debugf("Download OK %#v %#v", name, len(*data))
if msg.Event == config.EventAvatarDownload {
avatar = true
}
msg.Extra["file"] = append(msg.Extra["file"], config.FileInfo{
Name: name,
Data: data,
URL: url,
Comment: comment,
Avatar: avatar,
})
}
var emptyLineMatcher = regexp.MustCompile("\n+")
// RemoveEmptyNewLines collapses consecutive newline characters into a single one and
// trims any preceding or trailing newline characters as well.
func RemoveEmptyNewLines(msg string) string {
return emptyLineMatcher.ReplaceAllString(strings.Trim(msg, "\n"), "\n")
}
// ClipMessage trims a message to the specified length if it exceeds it and adds a warning
// to the message in case it does so.
func ClipMessage(text string, length int, clippingMessage string) string {
if clippingMessage == "" {
clippingMessage = " <clipped message>"
}
if len(text) > length {
text = text[:length-len(clippingMessage)]
if r, size := utf8.DecodeLastRuneInString(text); r == utf8.RuneError {
text = text[:len(text)-size]
}
text += clippingMessage
}
return text
}
// ParseMarkdown takes in an input string as markdown and parses it to html
func ParseMarkdown(input string) string {
extensions := parser.HardLineBreak | parser.NoIntraEmphasis | parser.FencedCode
markdownParser := parser.NewWithExtensions(extensions)
renderer := html.NewRenderer(html.RendererOptions{
Flags: 0,
})
parsedMarkdown := markdown.ToHTML([]byte(input), markdownParser, renderer)
res := string(parsedMarkdown)
res = strings.TrimPrefix(res, "<p>")
res = strings.TrimSuffix(res, "</p>\n")
return res
}
// ConvertWebPToPNG converts input data (which should be WebP format) to PNG format
func ConvertWebPToPNG(data *[]byte) error {
r := bytes.NewReader(*data)
m, err := webp.Decode(r)
if err != nil {
return err
}
var output []byte
w := bytes.NewBuffer(output)
if err := png.Encode(w, m); err != nil {
return err
}
*data = w.Bytes()
return nil
}

View File

@ -1,127 +0,0 @@
package helper
import (
"io/ioutil"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
const testLineLength = 64
var lineSplittingTestCases = map[string]struct {
input string
splitOutput []string
nonSplitOutput []string
}{
"Short single-line message": {
input: "short",
splitOutput: []string{"short"},
nonSplitOutput: []string{"short"},
},
"Long single-line message": {
input: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
splitOutput: []string{
"Lorem ipsum dolor sit amet, consectetur adipis <clipped message>",
"cing elit, sed do eiusmod tempor incididunt ut <clipped message>",
" labore et dolore magna aliqua.",
},
nonSplitOutput: []string{"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."},
},
"Short multi-line message": {
input: "I\ncan't\nget\nno\nsatisfaction!",
splitOutput: []string{
"I",
"can't",
"get",
"no",
"satisfaction!",
},
nonSplitOutput: []string{
"I",
"can't",
"get",
"no",
"satisfaction!",
},
},
"Long multi-line message": {
input: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n" +
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n" +
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n" +
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
splitOutput: []string{
"Lorem ipsum dolor sit amet, consectetur adipis <clipped message>",
"cing elit, sed do eiusmod tempor incididunt ut <clipped message>",
" labore et dolore magna aliqua.",
"Ut enim ad minim veniam, quis nostrud exercita <clipped message>",
"tion ullamco laboris nisi ut aliquip ex ea com <clipped message>",
"modo consequat.",
"Duis aute irure dolor in reprehenderit in volu <clipped message>",
"ptate velit esse cillum dolore eu fugiat nulla <clipped message>",
" pariatur.",
"Excepteur sint occaecat cupidatat non proident <clipped message>",
", sunt in culpa qui officia deserunt mollit an <clipped message>",
"im id est laborum.",
},
nonSplitOutput: []string{
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
},
},
"Message ending with new-line.": {
input: "Newline ending\n",
splitOutput: []string{"Newline ending"},
nonSplitOutput: []string{"Newline ending"},
},
"Long message containing UTF-8 multi-byte runes": {
input: "不布人個我此而及單石業喜資富下我河下日沒一我臺空達的常景便物沒為……子大我別名解成?生賣的全直黑,我自我結毛分洲了世當,是政福那是東;斯說",
splitOutput: []string{
"不布人個我此而及單石業喜資富下 <clipped message>",
"我河下日沒一我臺空達的常景便物 <clipped message>",
"沒為……子大我別名解成?生賣的 <clipped message>",
"全直黑,我自我結毛分洲了世當, <clipped message>",
"是政福那是東;斯說",
},
nonSplitOutput: []string{"不布人個我此而及單石業喜資富下我河下日沒一我臺空達的常景便物沒為……子大我別名解成?生賣的全直黑,我自我結毛分洲了世當,是政福那是東;斯說"},
},
}
func TestGetSubLines(t *testing.T) {
for testname, testcase := range lineSplittingTestCases {
splitLines := GetSubLines(testcase.input, testLineLength, "")
assert.Equalf(t, testcase.splitOutput, splitLines, "'%s' testcase should give expected lines with splitting.", testname)
for _, splitLine := range splitLines {
byteLength := len([]byte(splitLine))
assert.True(t, byteLength <= testLineLength, "Splitted line '%s' of testcase '%s' should not exceed the maximum byte-length (%d vs. %d).", splitLine, testcase, byteLength, testLineLength)
}
nonSplitLines := GetSubLines(testcase.input, 0, "")
assert.Equalf(t, testcase.nonSplitOutput, nonSplitLines, "'%s' testcase should give expected lines without splitting.", testname)
}
}
func TestConvertWebPToPNG(t *testing.T) {
if os.Getenv("LOCAL_TEST") == "" {
t.Skip()
}
input, err := ioutil.ReadFile("test.webp")
if err != nil {
t.Fail()
}
d := &input
err = ConvertWebPToPNG(d)
if err != nil {
t.Fail()
}
err = ioutil.WriteFile("test.png", *d, 0o644) // nolint:gosec
if err != nil {
t.Fail()
}
}

View File

@ -1,36 +0,0 @@
//go:build cgo
// +build cgo
package helper
import (
"fmt"
"github.com/Benau/tgsconverter/libtgsconverter"
"github.com/sirupsen/logrus"
)
func CanConvertTgsToX() error {
return nil
}
// ConvertTgsToX convert input data (which should be tgs format) to any format supported by libtgsconverter
func ConvertTgsToX(data *[]byte, outputFormat string, logger *logrus.Entry) error {
options := libtgsconverter.NewConverterOptions()
options.SetExtension(outputFormat)
blob, err := libtgsconverter.ImportFromData(*data, options)
if err != nil {
return fmt.Errorf("failed to run libtgsconverter.ImportFromData: %s", err.Error())
}
*data = blob
return nil
}
func SupportsFormat(format string) bool {
return libtgsconverter.SupportsExtension(format)
}
func LottieBackend() string {
return "libtgsconverter"
}

View File

@ -1,89 +0,0 @@
// +build !cgo
package helper
import (
"io/ioutil"
"os"
"os/exec"
"github.com/sirupsen/logrus"
)
// CanConvertTgsToX Checks whether the external command necessary for ConvertTgsToX works.
func CanConvertTgsToX() error {
// We depend on the fact that `lottie_convert.py --help` has exit status 0.
// Hyrum's Law predicted this, and Murphy's Law predicts that this will break eventually.
// However, there is no alternative like `lottie_convert.py --is-properly-installed`
cmd := exec.Command("lottie_convert.py", "--help")
return cmd.Run()
}
// ConvertTgsToWebP convert input data (which should be tgs format) to WebP format
// This relies on an external command, which is ugly, but works.
func ConvertTgsToX(data *[]byte, outputFormat string, logger *logrus.Entry) error {
// lottie can't handle input from a pipe, so write to a temporary file:
tmpInFile, err := ioutil.TempFile(os.TempDir(), "matterbridge-lottie-input-*.tgs")
if err != nil {
return err
}
tmpInFileName := tmpInFile.Name()
defer func() {
if removeErr := os.Remove(tmpInFileName); removeErr != nil {
logger.Errorf("Could not delete temporary (input) file %s: %v", tmpInFileName, removeErr)
}
}()
// lottie can handle writing to a pipe, but there is no way to do that platform-independently.
// "/dev/stdout" won't work on Windows, and "-" upsets Cairo for some reason. So we need another file:
tmpOutFile, err := ioutil.TempFile(os.TempDir(), "matterbridge-lottie-output-*.data")
if err != nil {
return err
}
tmpOutFileName := tmpOutFile.Name()
defer func() {
if removeErr := os.Remove(tmpOutFileName); removeErr != nil {
logger.Errorf("Could not delete temporary (output) file %s: %v", tmpOutFileName, removeErr)
}
}()
if _, writeErr := tmpInFile.Write(*data); writeErr != nil {
return writeErr
}
// Must close before calling lottie to avoid data races:
if closeErr := tmpInFile.Close(); closeErr != nil {
return closeErr
}
// Call lottie to transform:
cmd := exec.Command("lottie_convert.py", "--input-format", "lottie", "--output-format", outputFormat, tmpInFileName, tmpOutFileName)
cmd.Stdout = nil
cmd.Stderr = nil
// NB: lottie writes progress into to stderr in all cases.
_, stderr := cmd.Output()
if stderr != nil {
// 'stderr' already contains some parts of Stderr, because it was set to 'nil'.
return stderr
}
dataContents, err := ioutil.ReadFile(tmpOutFileName)
if err != nil {
return err
}
*data = dataContents
return nil
}
func SupportsFormat(format string) bool {
switch format {
case "png":
fallthrough
case "webp":
return true
default:
return false
}
return false
}
func LottieBackend() string {
return "lottie_convert.py"
}

View File

@ -1,265 +0,0 @@
package birc
import (
"bytes"
"fmt"
"io/ioutil"
"strconv"
"strings"
"time"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/lrstanley/girc"
"github.com/missdeer/golib/ic"
"github.com/paulrosania/go-charset/charset"
"github.com/saintfish/chardet"
// We need to import the 'data' package as an implicit dependency.
// See: https://godoc.org/github.com/paulrosania/go-charset/charset
_ "github.com/paulrosania/go-charset/data"
)
func (b *Birc) handleCharset(msg *config.Message) error {
if b.GetString("Charset") != "" {
switch b.GetString("Charset") {
case "gbk", "gb18030", "gb2312", "big5", "euc-kr", "euc-jp", "shift-jis", "iso-2022-jp":
msg.Text = ic.ConvertString("utf-8", b.GetString("Charset"), msg.Text)
default:
buf := new(bytes.Buffer)
w, err := charset.NewWriter(b.GetString("Charset"), buf)
if err != nil {
b.Log.Errorf("charset from utf-8 conversion failed: %s", err)
return err
}
fmt.Fprint(w, msg.Text)
w.Close()
msg.Text = buf.String()
}
}
return nil
}
// handleFiles returns true if we have handled the files, otherwise return false
func (b *Birc) handleFiles(msg *config.Message) bool {
if msg.Extra == nil {
return false
}
for _, rmsg := range helper.HandleExtra(msg, b.General) {
b.Local <- rmsg
}
if len(msg.Extra["file"]) == 0 {
return false
}
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
if fi.Comment != "" {
msg.Text += fi.Comment + " : "
}
if fi.URL != "" {
msg.Text = fi.URL
if fi.Comment != "" {
msg.Text = fi.Comment + " : " + fi.URL
}
}
b.Local <- config.Message{Text: msg.Text, Username: msg.Username, Channel: msg.Channel, Event: msg.Event}
}
return true
}
func (b *Birc) handleInvite(client *girc.Client, event girc.Event) {
if len(event.Params) != 2 {
return
}
channel := event.Params[1]
b.Log.Debugf("got invite for %s", channel)
if _, ok := b.channels[channel]; ok {
b.i.Cmd.Join(channel)
}
}
func (b *Birc) handleJoinPart(client *girc.Client, event girc.Event) {
if len(event.Params) == 0 {
b.Log.Debugf("handleJoinPart: empty Params? %#v", event)
return
}
channel := strings.ToLower(event.Params[0])
if event.Command == "KICK" && event.Params[1] == b.Nick {
b.Log.Infof("Got kicked from %s by %s", channel, event.Source.Name)
time.Sleep(time.Duration(b.GetInt("RejoinDelay")) * time.Second)
b.Remote <- config.Message{Username: "system", Text: "rejoin", Channel: channel, Account: b.Account, Event: config.EventRejoinChannels}
return
}
if event.Command == "QUIT" {
if event.Source.Name == b.Nick && strings.Contains(event.Last(), "Ping timeout") {
b.Log.Infof("%s reconnecting ..", b.Account)
b.Remote <- config.Message{Username: "system", Text: "reconnect", Channel: channel, Account: b.Account, Event: config.EventFailure}
return
}
}
if event.Source.Name != b.Nick {
if b.GetBool("nosendjoinpart") {
return
}
msg := config.Message{Username: "system", Text: event.Source.Name + " " + strings.ToLower(event.Command) + "s", Channel: channel, Account: b.Account, Event: config.EventJoinLeave}
if b.GetBool("verbosejoinpart") {
b.Log.Debugf("<= Sending verbose JOIN_LEAVE event from %s to gateway", b.Account)
msg = config.Message{Username: "system", Text: event.Source.Name + " (" + event.Source.Ident + "@" + event.Source.Host + ") " + strings.ToLower(event.Command) + "s", Channel: channel, Account: b.Account, Event: config.EventJoinLeave}
} else {
b.Log.Debugf("<= Sending JOIN_LEAVE event from %s to gateway", b.Account)
}
b.Log.Debugf("<= Message is %#v", msg)
b.Remote <- msg
return
}
b.Log.Debugf("handle %#v", event)
}
func (b *Birc) handleNewConnection(client *girc.Client, event girc.Event) {
b.Log.Debug("Registering callbacks")
i := b.i
b.Nick = event.Params[0]
i.Handlers.AddBg("PRIVMSG", b.handlePrivMsg)
i.Handlers.AddBg("CTCP_ACTION", b.handlePrivMsg)
i.Handlers.Add(girc.RPL_TOPICWHOTIME, b.handleTopicWhoTime)
i.Handlers.AddBg(girc.NOTICE, b.handleNotice)
i.Handlers.AddBg("JOIN", b.handleJoinPart)
i.Handlers.AddBg("PART", b.handleJoinPart)
i.Handlers.AddBg("QUIT", b.handleJoinPart)
i.Handlers.AddBg("KICK", b.handleJoinPart)
i.Handlers.Add("INVITE", b.handleInvite)
}
func (b *Birc) handleNickServ() {
if !b.GetBool("UseSASL") && b.GetString("NickServNick") != "" && b.GetString("NickServPassword") != "" {
b.Log.Debugf("Sending identify to nickserv %s", b.GetString("NickServNick"))
b.i.Cmd.Message(b.GetString("NickServNick"), "IDENTIFY "+b.GetString("NickServPassword"))
}
if strings.EqualFold(b.GetString("NickServNick"), "Q@CServe.quakenet.org") {
b.Log.Debugf("Authenticating %s against %s", b.GetString("NickServUsername"), b.GetString("NickServNick"))
b.i.Cmd.Message(b.GetString("NickServNick"), "AUTH "+b.GetString("NickServUsername")+" "+b.GetString("NickServPassword"))
}
// give nickserv some slack
time.Sleep(time.Second * 5)
b.authDone = true
}
func (b *Birc) handleNotice(client *girc.Client, event girc.Event) {
if strings.Contains(event.String(), "This nickname is registered") && event.Source.Name == b.GetString("NickServNick") {
b.handleNickServ()
} else {
b.handlePrivMsg(client, event)
}
}
func (b *Birc) handleOther(client *girc.Client, event girc.Event) {
if b.GetInt("DebugLevel") == 1 {
if event.Command != "CLIENT_STATE_UPDATED" &&
event.Command != "CLIENT_GENERAL_UPDATED" {
b.Log.Debugf("%#v", event.String())
}
return
}
switch event.Command {
case "372", "375", "376", "250", "251", "252", "253", "254", "255", "265", "266", "002", "003", "004", "005":
return
}
b.Log.Debugf("%#v", event.String())
}
func (b *Birc) handleOtherAuth(client *girc.Client, event girc.Event) {
b.handleNickServ()
b.handleRunCommands()
// we are now fully connected
// only send on first connection
if b.FirstConnection {
b.connected <- nil
}
}
func (b *Birc) handlePrivMsg(client *girc.Client, event girc.Event) {
if b.skipPrivMsg(event) {
return
}
rmsg := config.Message{
Username: event.Source.Name,
Channel: strings.ToLower(event.Params[0]),
Account: b.Account,
UserID: event.Source.Ident + "@" + event.Source.Host,
}
b.Log.Debugf("== Receiving PRIVMSG: %s %s %#v", event.Source.Name, event.Last(), event)
// set action event
if event.IsAction() {
rmsg.Event = config.EventUserAction
}
// set NOTICE event
if event.Command == "NOTICE" {
rmsg.Event = config.EventNoticeIRC
}
// strip action, we made an event if it was an action
rmsg.Text += event.StripAction()
// start detecting the charset
mycharset := b.GetString("Charset")
if mycharset == "" {
// detect what were sending so that we convert it to utf-8
detector := chardet.NewTextDetector()
result, err := detector.DetectBest([]byte(rmsg.Text))
if err != nil {
b.Log.Infof("detection failed for rmsg.Text: %#v", rmsg.Text)
return
}
b.Log.Debugf("detected %s confidence %#v", result.Charset, result.Confidence)
mycharset = result.Charset
// if we're not sure, just pick ISO-8859-1
if result.Confidence < 80 {
mycharset = "ISO-8859-1"
}
}
switch mycharset {
case "gbk", "gb18030", "gb2312", "big5", "euc-kr", "euc-jp", "shift-jis", "iso-2022-jp":
rmsg.Text = ic.ConvertString("utf-8", b.GetString("Charset"), rmsg.Text)
default:
r, err := charset.NewReader(mycharset, strings.NewReader(rmsg.Text))
if err != nil {
b.Log.Errorf("charset to utf-8 conversion failed: %s", err)
return
}
output, _ := ioutil.ReadAll(r)
rmsg.Text = string(output)
}
b.Log.Debugf("<= Sending message from %s on %s to gateway", event.Params[0], b.Account)
b.Remote <- rmsg
}
func (b *Birc) handleRunCommands() {
for _, cmd := range b.GetStringSlice("RunCommands") {
if err := b.i.Cmd.SendRaw(cmd); err != nil {
b.Log.Errorf("RunCommands %s failed: %s", cmd, err)
}
time.Sleep(time.Second)
}
}
func (b *Birc) handleTopicWhoTime(client *girc.Client, event girc.Event) {
parts := strings.Split(event.Params[2], "!")
t, err := strconv.ParseInt(event.Params[3], 10, 64)
if err != nil {
b.Log.Errorf("Invalid time stamp: %s", event.Params[3])
}
user := parts[0]
if len(parts) > 1 {
user += " [" + parts[1] + "]"
}
b.Log.Debugf("%s: Topic set by %s [%s]", event.Command, user, time.Unix(t, 0))
}

59
bridge/irc/helper.go Normal file
View File

@ -0,0 +1,59 @@
package birc
import (
"strings"
)
func tableformatter(nicks []string, nicksPerRow int, continued bool) string {
result := "|IRC users"
if continued {
result = "|(continued)"
}
for i := 0; i < 2; i++ {
for j := 1; j <= nicksPerRow && j <= len(nicks); j++ {
if i == 0 {
result += "|"
} else {
result += ":-|"
}
}
result += "\r\n|"
}
result += nicks[0] + "|"
for i := 1; i < len(nicks); i++ {
if i%nicksPerRow == 0 {
result += "\r\n|" + nicks[i] + "|"
} else {
result += nicks[i] + "|"
}
}
return result
}
func plainformatter(nicks []string, nicksPerRow int) string {
return strings.Join(nicks, ", ") + " currently on IRC"
}
func IsMarkup(message string) bool {
switch message[0] {
case '|':
fallthrough
case '#':
fallthrough
case '_':
fallthrough
case '*':
fallthrough
case '~':
fallthrough
case '-':
fallthrough
case ':':
fallthrough
case '>':
fallthrough
case '=':
return true
}
return false
}

View File

@ -3,381 +3,260 @@ package birc
import (
"crypto/tls"
"fmt"
"hash/crc32"
"io/ioutil"
"net"
"github.com/42wim/matterbridge/bridge/config"
log "github.com/Sirupsen/logrus"
ircm "github.com/sorcix/irc"
"github.com/thoj/go-ircevent"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/lrstanley/girc"
stripmd "github.com/writeas/go-strip-markdown"
// We need to import the 'data' package as an implicit dependency.
// See: https://godoc.org/github.com/paulrosania/go-charset/charset
_ "github.com/paulrosania/go-charset/data"
)
type Birc struct {
i *girc.Client
Nick string
names map[string][]string
connected chan error
Local chan config.Message // local queue for flood control
FirstConnection, authDone bool
MessageDelay, MessageQueue, MessageLength int
channels map[string]bool
*bridge.Config
i *irc.Connection
Nick string
names map[string][]string
Config *config.Protocol
origin string
protocol string
Remote chan config.Message
connected chan struct{}
Local chan config.Message // local queue for flood control
}
func New(cfg *bridge.Config) bridge.Bridger {
b := &Birc{}
b.Config = cfg
b.Nick = b.GetString("Nick")
b.names = make(map[string][]string)
b.connected = make(chan error)
b.channels = make(map[string]bool)
var flog *log.Entry
var protocol = "irc"
if b.GetInt("MessageDelay") == 0 {
b.MessageDelay = 1300
} else {
b.MessageDelay = b.GetInt("MessageDelay")
func init() {
flog = log.WithFields(log.Fields{"module": protocol})
}
func New(cfg config.Protocol, origin string, c chan config.Message) *Birc {
b := &Birc{}
b.Config = &cfg
b.Nick = b.Config.Nick
b.Remote = c
b.names = make(map[string][]string)
b.origin = origin
b.protocol = protocol
b.connected = make(chan struct{})
if b.Config.MessageDelay == 0 {
b.Config.MessageDelay = 1300
}
if b.GetInt("MessageQueue") == 0 {
b.MessageQueue = 30
} else {
b.MessageQueue = b.GetInt("MessageQueue")
if b.Config.MessageQueue == 0 {
b.Config.MessageQueue = 30
}
if b.GetInt("MessageLength") == 0 {
b.MessageLength = 400
} else {
b.MessageLength = b.GetInt("MessageLength")
}
b.FirstConnection = true
b.Local = make(chan config.Message, b.Config.MessageQueue+10)
return b
}
func (b *Birc) Command(msg *config.Message) string {
if msg.Text == "!users" {
b.i.Handlers.Add(girc.RPL_NAMREPLY, b.storeNames)
b.i.Handlers.Add(girc.RPL_ENDOFNAMES, b.endNames)
b.i.Cmd.SendRaw("NAMES " + msg.Channel) //nolint:errcheck
switch msg.Text {
case "!users":
b.i.AddCallback(ircm.RPL_ENDOFNAMES, b.endNames)
b.i.SendRaw("NAMES " + msg.Channel)
b.i.ClearCallback(ircm.RPL_ENDOFNAMES)
}
return ""
}
func (b *Birc) Connect() error {
b.Local = make(chan config.Message, b.MessageQueue+10)
b.Log.Infof("Connecting %s", b.GetString("Server"))
i, err := b.getClient()
flog.Infof("Connecting %s", b.Config.Server)
i := irc.IRC(b.Config.Nick, b.Config.Nick)
if log.GetLevel() == log.DebugLevel {
i.Debug = true
}
i.UseTLS = b.Config.UseTLS
i.UseSASL = b.Config.UseSASL
i.SASLLogin = b.Config.NickServNick
i.SASLPassword = b.Config.NickServPassword
i.TLSConfig = &tls.Config{InsecureSkipVerify: b.Config.SkipTLSVerify}
if b.Config.Password != "" {
i.Password = b.Config.Password
}
i.AddCallback(ircm.RPL_WELCOME, b.handleNewConnection)
err := i.Connect(b.Config.Server)
if err != nil {
return err
}
if b.GetBool("UseSASL") {
i.Config.SASL = &girc.SASLPlain{
User: b.GetString("NickServNick"),
Pass: b.GetString("NickServPassword"),
}
}
i.Handlers.Add(girc.RPL_WELCOME, b.handleNewConnection)
i.Handlers.Add(girc.RPL_ENDOFMOTD, b.handleOtherAuth)
i.Handlers.Add(girc.ERR_NOMOTD, b.handleOtherAuth)
i.Handlers.Add(girc.ALL_EVENTS, b.handleOther)
b.i = i
go b.doConnect()
err = <-b.connected
if err != nil {
return fmt.Errorf("connection failed %s", err)
}
b.Log.Info("Connection succeeded")
b.FirstConnection = false
if b.GetInt("DebugLevel") == 0 {
i.Handlers.Clear(girc.ALL_EVENTS)
select {
case <-b.connected:
flog.Info("Connection succeeded")
case <-time.After(time.Second * 30):
return fmt.Errorf("connection timed out")
}
i.Debug = false
go b.doSend()
return nil
}
func (b *Birc) Disconnect() error {
b.i.Close()
close(b.Local)
func (b *Birc) FullOrigin() string {
return b.protocol + "." + b.origin
}
func (b *Birc) JoinChannel(channel string) error {
b.i.Join(channel)
return nil
}
func (b *Birc) JoinChannel(channel config.ChannelInfo) error {
b.channels[channel.Name] = true
// need to check if we have nickserv auth done before joining channels
for {
if b.authDone {
break
}
time.Sleep(time.Second)
}
if channel.Options.Key != "" {
b.Log.Debugf("using key %s for channel %s", channel.Options.Key, channel.Name)
b.i.Cmd.JoinKey(channel.Name, channel.Options.Key)
} else {
b.i.Cmd.Join(channel.Name)
}
return nil
func (b *Birc) Name() string {
return b.protocol + "." + b.origin
}
func (b *Birc) Send(msg config.Message) (string, error) {
// ignore delete messages
if msg.Event == config.EventMsgDelete {
return "", nil
func (b *Birc) Protocol() string {
return b.protocol
}
func (b *Birc) Origin() string {
return b.origin
}
func (b *Birc) Send(msg config.Message) error {
flog.Debugf("Receiving %#v", msg)
if msg.FullOrigin == b.FullOrigin() {
return nil
}
b.Log.Debugf("=> Receiving %#v", msg)
// we can be in between reconnects #385
if !b.i.IsConnected() {
b.Log.Error("Not connected to server, dropping message")
return "", nil
}
// Execute a command
if strings.HasPrefix(msg.Text, "!") {
b.Command(&msg)
return nil
}
// convert to specified charset
if err := b.handleCharset(&msg); err != nil {
return "", err
}
// handle files, return if we're done here
if ok := b.handleFiles(&msg); ok {
return "", nil
}
var msgLines []string
if b.GetBool("StripMarkdown") {
msg.Text = stripmd.Strip(msg.Text)
}
if b.GetBool("MessageSplit") {
msgLines = helper.GetSubLines(msg.Text, b.MessageLength, b.GetString("MessageClipped"))
} else {
msgLines = helper.GetSubLines(msg.Text, 0, b.GetString("MessageClipped"))
}
for i := range msgLines {
if len(b.Local) >= b.MessageQueue {
b.Log.Debugf("flooding, dropping message (queue at %d)", len(b.Local))
return "", nil
}
msg.Text = msgLines[i]
b.Local <- msg
}
return "", nil
}
func (b *Birc) doConnect() {
for {
if err := b.i.Connect(); err != nil {
b.Log.Errorf("disconnect: error: %s", err)
if b.FirstConnection {
b.connected <- err
return
nick := config.GetNick(&msg, b.Config)
for _, text := range strings.Split(msg.Text, "\n") {
if len(b.Local) < b.Config.MessageQueue {
if len(b.Local) == b.Config.MessageQueue-1 {
text = text + " <message clipped>"
}
b.Local <- config.Message{Text: text, Username: nick, Channel: msg.Channel}
} else {
b.Log.Info("disconnect: client requested quit")
flog.Debugf("flooding, dropping message (queue at %d)", len(b.Local))
}
b.Log.Info("reconnecting in 30 seconds...")
time.Sleep(30 * time.Second)
b.i.Handlers.Clear(girc.RPL_WELCOME)
b.i.Handlers.Add(girc.RPL_WELCOME, func(client *girc.Client, event girc.Event) {
b.Remote <- config.Message{Username: "system", Text: "rejoin", Channel: "", Account: b.Account, Event: config.EventRejoinChannels}
// set our correct nick on reconnect if necessary
b.Nick = event.Source.Name
})
}
}
// Sanitize nicks for RELAYMSG: replace IRC characters with special meanings with "-"
func sanitizeNick(nick string) string {
sanitize := func(r rune) rune {
if strings.ContainsRune("!+%@&#$:'\"?*,. ", r) {
return '-'
}
return r
}
return strings.Map(sanitize, nick)
return nil
}
func (b *Birc) doSend() {
rate := time.Millisecond * time.Duration(b.MessageDelay)
throttle := time.NewTicker(rate)
rate := time.Millisecond * time.Duration(b.Config.MessageDelay)
throttle := time.Tick(rate)
for msg := range b.Local {
<-throttle.C
username := msg.Username
// Optional support for the proposed RELAYMSG extension, described at
// https://github.com/jlu5/ircv3-specifications/blob/master/extensions/relaymsg.md
// nolint:nestif
if (b.i.HasCapability("overdrivenetworks.com/relaymsg") || b.i.HasCapability("draft/relaymsg")) &&
b.GetBool("UseRelayMsg") {
username = sanitizeNick(username)
text := msg.Text
// Work around girc chomping leading commas on single word messages?
if strings.HasPrefix(text, ":") && !strings.ContainsRune(text, ' ') {
text = ":" + text
}
if msg.Event == config.EventUserAction {
b.i.Cmd.SendRawf("RELAYMSG %s %s :\x01ACTION %s\x01", msg.Channel, username, text) //nolint:errcheck
} else {
b.Log.Debugf("Sending RELAYMSG to channel %s: nick=%s", msg.Channel, username)
b.i.Cmd.SendRawf("RELAYMSG %s %s :%s", msg.Channel, username, text) //nolint:errcheck
}
} else {
if b.GetBool("Colornicks") {
checksum := crc32.ChecksumIEEE([]byte(msg.Username))
colorCode := checksum%14 + 2 // quick fix - prevent white or black color codes
username = fmt.Sprintf("\x03%02d%s\x0F", colorCode, msg.Username)
}
switch msg.Event {
case config.EventUserAction:
b.i.Cmd.Action(msg.Channel, username+msg.Text)
case config.EventNoticeIRC:
b.Log.Debugf("Sending notice to channel %s", msg.Channel)
b.i.Cmd.Notice(msg.Channel, username+msg.Text)
default:
b.Log.Debugf("Sending to channel %s", msg.Channel)
b.i.Cmd.Message(msg.Channel, username+msg.Text)
}
}
<-throttle
b.i.Privmsg(msg.Channel, msg.Username+msg.Text)
}
}
// validateInput validates the server/port/nick configuration. Returns a *girc.Client if successful
func (b *Birc) getClient() (*girc.Client, error) {
server, portstr, err := net.SplitHostPort(b.GetString("Server"))
if err != nil {
return nil, err
}
port, err := strconv.Atoi(portstr)
if err != nil {
return nil, err
}
user := b.GetString("UserName")
if user == "" {
user = b.GetString("Nick")
}
// fix strict user handling of girc
for !girc.IsValidUser(user) {
if len(user) == 1 || len(user) == 0 {
user = "matterbridge"
break
}
user = user[1:]
}
realName := b.GetString("RealName")
if realName == "" {
realName = b.GetString("Nick")
}
debug := ioutil.Discard
if b.GetInt("DebugLevel") == 2 {
debug = b.Log.Writer()
}
pingDelay, err := time.ParseDuration(b.GetString("pingdelay"))
if err != nil || pingDelay == 0 {
pingDelay = time.Minute
}
b.Log.Debugf("setting pingdelay to %s", pingDelay)
i := girc.New(girc.Config{
Server: server,
ServerPass: b.GetString("Password"),
Port: port,
Nick: b.GetString("Nick"),
User: user,
Name: realName,
SSL: b.GetBool("UseTLS"),
Bind: b.GetString("Bind"),
TLSConfig: &tls.Config{InsecureSkipVerify: b.GetBool("SkipTLSVerify"), ServerName: server}, //nolint:gosec
PingDelay: pingDelay,
// skip gIRC internal rate limiting, since we have our own throttling
AllowFlood: true,
Debug: debug,
SupportedCaps: map[string][]string{"overdrivenetworks.com/relaymsg": nil, "draft/relaymsg": nil},
})
return i, nil
}
func (b *Birc) endNames(client *girc.Client, event girc.Event) {
channel := event.Params[1]
func (b *Birc) endNames(event *irc.Event) {
channel := event.Arguments[1]
sort.Strings(b.names[channel])
maxNamesPerPost := (300 / b.nicksPerRow()) * b.nicksPerRow()
continued := false
for len(b.names[channel]) > maxNamesPerPost {
b.Remote <- config.Message{
Username: b.Nick, Text: b.formatnicks(b.names[channel][0:maxNamesPerPost]),
Channel: channel, Account: b.Account,
}
b.Remote <- config.Message{Username: b.Nick, Text: b.formatnicks(b.names[channel][0:maxNamesPerPost], continued),
Channel: channel, Origin: b.origin, Protocol: b.protocol, FullOrigin: b.FullOrigin()}
b.names[channel] = b.names[channel][maxNamesPerPost:]
continued = true
}
b.Remote <- config.Message{
Username: b.Nick, Text: b.formatnicks(b.names[channel]),
Channel: channel, Account: b.Account,
}
b.Remote <- config.Message{Username: b.Nick, Text: b.formatnicks(b.names[channel], continued), Channel: channel,
Origin: b.origin, Protocol: b.protocol, FullOrigin: b.FullOrigin()}
b.names[channel] = nil
b.i.Handlers.Clear(girc.RPL_NAMREPLY)
b.i.Handlers.Clear(girc.RPL_ENDOFNAMES)
}
func (b *Birc) skipPrivMsg(event girc.Event) bool {
// Our nick can be changed
b.Nick = b.i.GetNick()
func (b *Birc) handleNewConnection(event *irc.Event) {
flog.Debug("Registering callbacks")
i := b.i
b.Nick = event.Arguments[0]
i.AddCallback("PRIVMSG", b.handlePrivMsg)
i.AddCallback("CTCP_ACTION", b.handlePrivMsg)
i.AddCallback(ircm.RPL_TOPICWHOTIME, b.handleTopicWhoTime)
i.AddCallback(ircm.RPL_NAMREPLY, b.storeNames)
i.AddCallback(ircm.NOTICE, b.handleNotice)
//i.AddCallback(ircm.RPL_MYINFO, func(e *irc.Event) { flog.Infof("%s: %s", e.Code, strings.Join(e.Arguments[1:], " ")) })
i.AddCallback("PING", func(e *irc.Event) {
i.SendRaw("PONG :" + e.Message())
flog.Debugf("PING/PONG")
})
i.AddCallback("*", b.handleOther)
// we are now fully connected
b.connected <- struct{}{}
}
// freenode doesn't send 001 as first reply
if event.Command == "NOTICE" && len(event.Params) != 2 {
return true
func (b *Birc) handleNotice(event *irc.Event) {
if strings.Contains(event.Message(), "This nickname is registered") && event.Nick == b.Config.NickServNick {
b.i.Privmsg(b.Config.NickServNick, "IDENTIFY "+b.Config.NickServPassword)
} else {
b.handlePrivMsg(event)
}
}
func (b *Birc) handleOther(event *irc.Event) {
switch event.Code {
case "372", "375", "376", "250", "251", "252", "253", "254", "255", "265", "266", "002", "003", "004", "005":
return
}
flog.Debugf("%#v", event.Raw)
}
func (b *Birc) handlePrivMsg(event *irc.Event) {
// don't forward queries to the bot
if event.Params[0] == b.Nick {
return true
if event.Arguments[0] == b.Nick {
return
}
// don't forward message from ourself
if event.Source.Name == b.Nick {
return true
if event.Nick == b.Nick {
return
}
// don't forward messages we sent via RELAYMSG
if relayedNick, ok := event.Tags.Get("draft/relaymsg"); ok && relayedNick == b.Nick {
return true
flog.Debugf("handlePrivMsg() %s %s %#v", event.Nick, event.Message(), event)
msg := ""
if event.Code == "CTCP_ACTION" {
msg = event.Nick + " "
}
// This is the old name of the cap sent in spoofed messages; I've kept this in
// for compatibility reasons
if relayedNick, ok := event.Tags.Get("relaymsg"); ok && relayedNick == b.Nick {
return true
msg += event.Message()
// strip IRC colors
re := regexp.MustCompile(`[[:cntrl:]](\d+,|)\d+`)
msg = re.ReplaceAllString(msg, "")
flog.Debugf("Sending message from %s on %s to gateway", event.Arguments[0], b.FullOrigin())
b.Remote <- config.Message{Username: event.Nick, Text: msg, Channel: event.Arguments[0], Origin: b.origin, Protocol: b.protocol, FullOrigin: b.FullOrigin()}
}
func (b *Birc) handleTopicWhoTime(event *irc.Event) {
parts := strings.Split(event.Arguments[2], "!")
t, err := strconv.ParseInt(event.Arguments[3], 10, 64)
if err != nil {
flog.Errorf("Invalid time stamp: %s", event.Arguments[3])
}
return false
user := parts[0]
if len(parts) > 1 {
user += " [" + parts[1] + "]"
}
flog.Debugf("%s: Topic set by %s [%s]", event.Code, user, time.Unix(t, 0))
}
func (b *Birc) nicksPerRow() int {
return 4
/*
if b.Config.Mattermost.NicksPerRow < 1 {
return 4
}
return b.Config.Mattermost.NicksPerRow
*/
}
func (b *Birc) storeNames(client *girc.Client, event girc.Event) {
channel := event.Params[2]
func (b *Birc) storeNames(event *irc.Event) {
channel := event.Arguments[2]
b.names[channel] = append(
b.names[channel],
strings.Split(strings.TrimSpace(event.Last()), " ")...)
strings.Split(strings.TrimSpace(event.Message()), " ")...)
}
func (b *Birc) formatnicks(nicks []string) string {
return strings.Join(nicks, ", ") + " currently on IRC"
func (b *Birc) formatnicks(nicks []string, continued bool) string {
return plainformatter(nicks, b.nicksPerRow())
/*
switch b.Config.Mattermost.NickFormatter {
case "table":
return tableformatter(nicks, b.nicksPerRow(), continued)
default:
return plainformatter(nicks, b.nicksPerRow())
}
*/
}

View File

@ -1,59 +0,0 @@
package bkeybase
import (
"strconv"
"github.com/42wim/matterbridge/bridge/config"
"github.com/keybase/go-keybase-chat-bot/kbchat/types/chat1"
)
func (b *Bkeybase) handleKeybase() {
sub, err := b.kbc.ListenForNewTextMessages()
if err != nil {
b.Log.Errorf("Error listening: %s", err.Error())
}
go func() {
for {
msg, err := sub.Read()
if err != nil {
b.Log.Errorf("failed to read message: %s", err.Error())
}
if msg.Message.Content.TypeName != "text" {
continue
}
if msg.Message.Sender.Username == b.kbc.GetUsername() {
continue
}
b.handleMessage(msg.Message)
}
}()
}
func (b *Bkeybase) handleMessage(msg chat1.MsgSummary) {
b.Log.Debugf("== Receiving event: %#v", msg)
if msg.Channel.TopicName != b.channel || msg.Channel.Name != b.team {
return
}
if msg.Sender.Username != b.kbc.GetUsername() {
// TODO download avatar
// Create our message
rmsg := config.Message{Username: msg.Sender.Username, Text: msg.Content.Text.Body, UserID: string(msg.Sender.Uid), Channel: msg.Channel.TopicName, ID: strconv.Itoa(int(msg.Id)), Account: b.Account}
// Text must be a string
if msg.Content.TypeName != "text" {
b.Log.Errorf("message is not text")
return
}
b.Log.Debugf("<= Sending message from %s on %s to gateway", msg.Sender.Username, msg.Channel.Name)
b.Remote <- rmsg
}
}

View File

@ -1,106 +0,0 @@
package bkeybase
import (
"io/ioutil"
"os"
"path/filepath"
"strconv"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/keybase/go-keybase-chat-bot/kbchat"
)
// Bkeybase bridge structure
type Bkeybase struct {
kbc *kbchat.API
user string
channel string
team string
*bridge.Config
}
// New initializes Bkeybase object and sets team
func New(cfg *bridge.Config) bridge.Bridger {
b := &Bkeybase{Config: cfg}
b.team = b.Config.GetString("Team")
return b
}
// Connect starts keybase API and listener loop
func (b *Bkeybase) Connect() error {
var err error
b.Log.Infof("Connecting %s", b.GetString("Team"))
// use default keybase location (`keybase`)
b.kbc, err = kbchat.Start(kbchat.RunOptions{})
if err != nil {
return err
}
b.user = b.kbc.GetUsername()
b.Log.Info("Connection succeeded")
go b.handleKeybase()
return nil
}
// Disconnect doesn't do anything for now
func (b *Bkeybase) Disconnect() error {
return nil
}
// JoinChannel sets channel name in struct
func (b *Bkeybase) JoinChannel(channel config.ChannelInfo) error {
if _, err := b.kbc.JoinChannel(b.team, channel.Name); err != nil {
return err
}
b.channel = channel.Name
return nil
}
// Send receives bridge messages and sends them to Keybase chat room
func (b *Bkeybase) Send(msg config.Message) (string, error) {
b.Log.Debugf("=> Receiving %#v", msg)
// Handle /me events
if msg.Event == config.EventUserAction {
msg.Text = "_" + msg.Text + "_"
}
// Delete message if we have an ID
// Delete message not supported by keybase go library yet
// Edit message if we have an ID
// kbchat lib does not support message editing yet
if len(msg.Extra["file"]) > 0 {
// Upload a file
dir, err := ioutil.TempDir("", "matterbridge")
if err != nil {
return "", err
}
defer os.RemoveAll(dir)
for _, f := range msg.Extra["file"] {
fname := f.(config.FileInfo).Name
fdata := *f.(config.FileInfo).Data
fcaption := f.(config.FileInfo).Comment
fpath := filepath.Join(dir, fname)
if err = ioutil.WriteFile(fpath, fdata, 0600); err != nil {
return "", err
}
_, _ = b.kbc.SendAttachmentByTeam(b.team, &b.channel, fpath, fcaption)
}
return "", nil
}
// Send regular message
text := msg.Username + msg.Text
resp, err := b.kbc.SendMessageByTeamName(b.team, &b.channel, text)
if err != nil {
return "", err
}
return strconv.Itoa(int(*resp.Result.MessageID)), err
}

View File

@ -1,215 +0,0 @@
package bmatrix
import (
"encoding/json"
"errors"
"fmt"
"html"
"strings"
"time"
matrix "github.com/matrix-org/gomatrix"
)
func newMatrixUsername(username string) *matrixUsername {
mUsername := new(matrixUsername)
// check if we have a </tag>. if we have, we don't escape HTML. #696
if htmlTag.MatchString(username) {
mUsername.formatted = username
// remove the HTML formatting for beautiful push messages #1188
mUsername.plain = htmlReplacementTag.ReplaceAllString(username, "")
} else {
mUsername.formatted = html.EscapeString(username)
mUsername.plain = username
}
return mUsername
}
// getRoomID retrieves a matching room ID from the channel name.
func (b *Bmatrix) getRoomID(channel string) string {
b.RLock()
defer b.RUnlock()
for ID, name := range b.RoomMap {
if name == channel {
return ID
}
}
return ""
}
// interface2Struct marshals and immediately unmarshals an interface.
// Useful for converting map[string]interface{} to a struct.
func interface2Struct(in interface{}, out interface{}) error {
jsonObj, err := json.Marshal(in)
if err != nil {
return err //nolint:wrapcheck
}
return json.Unmarshal(jsonObj, out)
}
// getDisplayName retrieves the displayName for mxid, querying the homserver if the mxid is not in the cache.
func (b *Bmatrix) getDisplayName(mxid string) string {
if b.GetBool("UseUserName") {
return mxid[1:]
}
b.RLock()
if val, present := b.NicknameMap[mxid]; present {
b.RUnlock()
return val.displayName
}
b.RUnlock()
displayName, err := b.mc.GetDisplayName(mxid)
var httpError *matrix.HTTPError
if errors.As(err, &httpError) {
b.Log.Warnf("Couldn't retrieve the display name for %s", mxid)
}
if err != nil {
return b.cacheDisplayName(mxid, mxid[1:])
}
return b.cacheDisplayName(mxid, displayName.DisplayName)
}
// cacheDisplayName stores the mapping between a mxid and a display name, to be reused later without performing a query to the homserver.
// Note that old entries are cleaned when this function is called.
func (b *Bmatrix) cacheDisplayName(mxid string, displayName string) string {
now := time.Now()
// scan to delete old entries, to stop memory usage from becoming too high with old entries.
// In addition, we also detect if another user have the same username, and if so, we append their mxids to their usernames to differentiate them.
toDelete := []string{}
conflict := false
b.Lock()
for mxid, v := range b.NicknameMap {
// to prevent username reuse across matrix servers - or even on the same server, append
// the mxid to the username when there is a conflict
if v.displayName == displayName {
conflict = true
// TODO: it would be nice to be able to rename previous messages from this user.
// The current behavior is that only users with clashing usernames and *that have spoken since the bridge last started* will get their mxids shown, and I don't know if that's the expected behavior.
v.displayName = fmt.Sprintf("%s (%s)", displayName, mxid)
b.NicknameMap[mxid] = v
}
if now.Sub(v.lastUpdated) > 10*time.Minute {
toDelete = append(toDelete, mxid)
}
}
if conflict {
displayName = fmt.Sprintf("%s (%s)", displayName, mxid)
}
for _, v := range toDelete {
delete(b.NicknameMap, v)
}
b.NicknameMap[mxid] = NicknameCacheEntry{
displayName: displayName,
lastUpdated: now,
}
b.Unlock()
return displayName
}
// handleError converts errors into httpError.
//nolint:exhaustivestruct
func handleError(err error) *httpError {
var mErr matrix.HTTPError
if !errors.As(err, &mErr) {
return &httpError{
Err: "not a HTTPError",
}
}
var httpErr httpError
if err := json.Unmarshal(mErr.Contents, &httpErr); err != nil {
return &httpError{
Err: "unmarshal failed",
}
}
return &httpErr
}
func (b *Bmatrix) containsAttachment(content map[string]interface{}) bool {
// Skip empty messages
if content["msgtype"] == nil {
return false
}
// Only allow image,video or file msgtypes
if !(content["msgtype"].(string) == "m.image" ||
content["msgtype"].(string) == "m.video" ||
content["msgtype"].(string) == "m.file") {
return false
}
return true
}
// getAvatarURL returns the avatar URL of the specified sender.
func (b *Bmatrix) getAvatarURL(sender string) string {
urlPath := b.mc.BuildURL("profile", sender, "avatar_url")
s := struct {
AvatarURL string `json:"avatar_url"`
}{}
err := b.mc.MakeRequest("GET", urlPath, nil, &s)
if err != nil {
b.Log.Errorf("getAvatarURL failed: %s", err)
return ""
}
url := strings.ReplaceAll(s.AvatarURL, "mxc://", b.GetString("Server")+"/_matrix/media/r0/thumbnail/")
if url != "" {
url += "?width=37&height=37&method=crop"
}
return url
}
// handleRatelimit handles the ratelimit errors and return if we're ratelimited and the amount of time to sleep
func (b *Bmatrix) handleRatelimit(err error) (time.Duration, bool) {
httpErr := handleError(err)
if httpErr.Errcode != "M_LIMIT_EXCEEDED" {
return 0, false
}
b.Log.Debugf("ratelimited: %s", httpErr.Err)
b.Log.Infof("getting ratelimited by matrix, sleeping approx %d seconds before retrying", httpErr.RetryAfterMs/1000)
return time.Duration(httpErr.RetryAfterMs) * time.Millisecond, true
}
// retry function will check if we're ratelimited and retries again when backoff time expired
// returns original error if not 429 ratelimit
func (b *Bmatrix) retry(f func() error) error {
b.rateMutex.Lock()
defer b.rateMutex.Unlock()
for {
if err := f(); err != nil {
if backoff, ok := b.handleRatelimit(err); ok {
time.Sleep(backoff)
} else {
return err
}
} else {
return nil
}
}
}

View File

@ -1,662 +0,0 @@
package bmatrix
import (
"bytes"
"fmt"
"mime"
"regexp"
"strings"
"sync"
"time"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
matrix "github.com/matrix-org/gomatrix"
)
var (
htmlTag = regexp.MustCompile("</.*?>")
htmlReplacementTag = regexp.MustCompile("<[^>]*>")
)
type NicknameCacheEntry struct {
displayName string
lastUpdated time.Time
}
type Bmatrix struct {
mc *matrix.Client
UserID string
NicknameMap map[string]NicknameCacheEntry
RoomMap map[string]string
rateMutex sync.RWMutex
sync.RWMutex
*bridge.Config
}
type httpError struct {
Errcode string `json:"errcode"`
Err string `json:"error"`
RetryAfterMs int `json:"retry_after_ms"`
}
type matrixUsername struct {
plain string
formatted string
}
// SubTextMessage represents the new content of the message in edit messages.
type SubTextMessage struct {
MsgType string `json:"msgtype"`
Body string `json:"body"`
}
// MessageRelation explains how the current message relates to a previous message.
// Notably used for message edits.
type MessageRelation struct {
EventID string `json:"event_id"`
Type string `json:"rel_type"`
}
type EditedMessage struct {
NewContent SubTextMessage `json:"m.new_content"`
RelatedTo MessageRelation `json:"m.relates_to"`
matrix.TextMessage
}
type InReplyToRelationContent struct {
EventID string `json:"event_id"`
}
type InReplyToRelation struct {
InReplyTo InReplyToRelationContent `json:"m.in_reply_to"`
}
type ReplyMessage struct {
RelatedTo InReplyToRelation `json:"m.relates_to"`
matrix.TextMessage
}
func New(cfg *bridge.Config) bridge.Bridger {
b := &Bmatrix{Config: cfg}
b.RoomMap = make(map[string]string)
b.NicknameMap = make(map[string]NicknameCacheEntry)
return b
}
func (b *Bmatrix) Connect() error {
var err error
b.Log.Infof("Connecting %s", b.GetString("Server"))
if b.GetString("MxID") != "" && b.GetString("Token") != "" {
b.mc, err = matrix.NewClient(
b.GetString("Server"), b.GetString("MxID"), b.GetString("Token"),
)
if err != nil {
return err
}
b.UserID = b.GetString("MxID")
b.Log.Info("Using existing Matrix credentials")
} else {
b.mc, err = matrix.NewClient(b.GetString("Server"), "", "")
if err != nil {
return err
}
resp, err := b.mc.Login(&matrix.ReqLogin{
Type: "m.login.password",
User: b.GetString("Login"),
Password: b.GetString("Password"),
Identifier: matrix.NewUserIdentifier(b.GetString("Login")),
})
if err != nil {
return err
}
b.mc.SetCredentials(resp.UserID, resp.AccessToken)
b.UserID = resp.UserID
b.Log.Info("Connection succeeded")
}
go b.handlematrix()
return nil
}
func (b *Bmatrix) Disconnect() error {
return nil
}
func (b *Bmatrix) JoinChannel(channel config.ChannelInfo) error {
return b.retry(func() error {
resp, err := b.mc.JoinRoom(channel.Name, "", nil)
if err != nil {
return err
}
b.Lock()
b.RoomMap[resp.RoomID] = channel.Name
b.Unlock()
return nil
})
}
func (b *Bmatrix) Send(msg config.Message) (string, error) {
b.Log.Debugf("=> Receiving %#v", msg)
channel := b.getRoomID(msg.Channel)
b.Log.Debugf("Channel %s maps to channel id %s", msg.Channel, channel)
username := newMatrixUsername(msg.Username)
// Make a action /me of the message
if msg.Event == config.EventUserAction {
m := matrix.TextMessage{
MsgType: "m.emote",
Body: username.plain + msg.Text,
FormattedBody: username.formatted + msg.Text,
}
msgID := ""
err := b.retry(func() error {
resp, err := b.mc.SendMessageEvent(channel, "m.room.message", m)
if err != nil {
return err
}
msgID = resp.EventID
return err
})
return msgID, err
}
// Delete message
if msg.Event == config.EventMsgDelete {
if msg.ID == "" {
return "", nil
}
msgID := ""
err := b.retry(func() error {
resp, err := b.mc.RedactEvent(channel, msg.ID, &matrix.ReqRedact{})
if err != nil {
return err
}
msgID = resp.EventID
return err
})
return msgID, err
}
// Upload a file if it exists
if msg.Extra != nil {
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
rmsg := rmsg
err := b.retry(func() error {
_, err := b.mc.SendText(channel, rmsg.Username+rmsg.Text)
return err
})
if err != nil {
b.Log.Errorf("sendText failed: %s", err)
}
}
// check if we have files to upload (from slack, telegram or mattermost)
if len(msg.Extra["file"]) > 0 {
return b.handleUploadFiles(&msg, channel)
}
}
// Edit message if we have an ID
if msg.ID != "" {
rmsg := EditedMessage{TextMessage: matrix.TextMessage{
Body: username.plain + msg.Text,
MsgType: "m.text",
}}
if b.GetBool("HTMLDisable") {
rmsg.TextMessage.FormattedBody = username.formatted + "* " + msg.Text
} else {
rmsg.Format = "org.matrix.custom.html"
rmsg.TextMessage.FormattedBody = username.formatted + "* " + helper.ParseMarkdown(msg.Text)
}
rmsg.NewContent = SubTextMessage{
Body: rmsg.TextMessage.Body,
MsgType: "m.text",
}
rmsg.RelatedTo = MessageRelation{
EventID: msg.ID,
Type: "m.replace",
}
err := b.retry(func() error {
_, err := b.mc.SendMessageEvent(channel, "m.room.message", rmsg)
return err
})
if err != nil {
return "", err
}
return msg.ID, nil
}
// Use notices to send join/leave events
if msg.Event == config.EventJoinLeave {
m := matrix.TextMessage{
MsgType: "m.notice",
Body: username.plain + msg.Text,
FormattedBody: username.formatted + msg.Text,
}
var (
resp *matrix.RespSendEvent
err error
)
err = b.retry(func() error {
resp, err = b.mc.SendMessageEvent(channel, "m.room.message", m)
return err
})
if err != nil {
return "", err
}
return resp.EventID, err
}
if b.GetBool("HTMLDisable") {
var (
resp *matrix.RespSendEvent
err error
)
err = b.retry(func() error {
resp, err = b.mc.SendText(channel, username.plain+msg.Text)
return err
})
if err != nil {
return "", err
}
return resp.EventID, err
}
if msg.ParentValid() {
m := ReplyMessage{
TextMessage: matrix.TextMessage{
MsgType: "m.text",
Body: username.plain + msg.Text,
FormattedBody: username.formatted + helper.ParseMarkdown(msg.Text),
},
}
m.RelatedTo = InReplyToRelation{
InReplyTo: InReplyToRelationContent{
EventID: msg.ParentID,
},
}
var (
resp *matrix.RespSendEvent
err error
)
err = b.retry(func() error {
resp, err = b.mc.SendMessageEvent(channel, "m.room.message", m)
return err
})
if err != nil {
return "", err
}
return resp.EventID, err
}
// Post normal message with HTML support (eg riot.im)
var (
resp *matrix.RespSendEvent
err error
)
err = b.retry(func() error {
resp, err = b.mc.SendFormattedText(channel, username.plain+msg.Text,
username.formatted+helper.ParseMarkdown(msg.Text))
return err
})
if err != nil {
return "", err
}
return resp.EventID, err
}
func (b *Bmatrix) handlematrix() {
syncer := b.mc.Syncer.(*matrix.DefaultSyncer)
syncer.OnEventType("m.room.redaction", b.handleEvent)
syncer.OnEventType("m.room.message", b.handleEvent)
syncer.OnEventType("m.room.member", b.handleMemberChange)
go func() {
for {
if b == nil {
return
}
if err := b.mc.Sync(); err != nil {
b.Log.Println("Sync() returned ", err)
}
}
}()
}
func (b *Bmatrix) handleEdit(ev *matrix.Event, rmsg config.Message) bool {
relationInterface, present := ev.Content["m.relates_to"]
newContentInterface, present2 := ev.Content["m.new_content"]
if !(present && present2) {
return false
}
var relation MessageRelation
if err := interface2Struct(relationInterface, &relation); err != nil {
b.Log.Warnf("Couldn't parse 'm.relates_to' object with value %#v", relationInterface)
return false
}
var newContent SubTextMessage
if err := interface2Struct(newContentInterface, &newContent); err != nil {
b.Log.Warnf("Couldn't parse 'm.new_content' object with value %#v", newContentInterface)
return false
}
if relation.Type != "m.replace" {
return false
}
rmsg.ID = relation.EventID
rmsg.Text = newContent.Body
b.Remote <- rmsg
return true
}
func (b *Bmatrix) handleReply(ev *matrix.Event, rmsg config.Message) bool {
relationInterface, present := ev.Content["m.relates_to"]
if !present {
return false
}
var relation InReplyToRelation
if err := interface2Struct(relationInterface, &relation); err != nil {
// probably fine
return false
}
body := rmsg.Text
for strings.HasPrefix(body, "> ") {
lineIdx := strings.IndexRune(body, '\n')
if lineIdx == -1 {
body = ""
} else {
body = body[(lineIdx + 1):]
}
}
rmsg.Text = body
rmsg.ParentID = relation.InReplyTo.EventID
b.Remote <- rmsg
return true
}
func (b *Bmatrix) handleMemberChange(ev *matrix.Event) {
// Update the displayname on join messages, according to https://matrix.org/docs/spec/client_server/r0.6.1#events-on-change-of-profile-information
if ev.Content["membership"] == "join" {
if dn, ok := ev.Content["displayname"].(string); ok {
b.cacheDisplayName(ev.Sender, dn)
}
}
}
func (b *Bmatrix) handleEvent(ev *matrix.Event) {
b.Log.Debugf("== Receiving event: %#v", ev)
if ev.Sender != b.UserID {
b.RLock()
channel, ok := b.RoomMap[ev.RoomID]
b.RUnlock()
if !ok {
b.Log.Debugf("Unknown room %s", ev.RoomID)
return
}
// Create our message
rmsg := config.Message{
Username: b.getDisplayName(ev.Sender),
Channel: channel,
Account: b.Account,
UserID: ev.Sender,
ID: ev.ID,
Avatar: b.getAvatarURL(ev.Sender),
}
// Remove homeserver suffix if configured
if b.GetBool("NoHomeServerSuffix") {
re := regexp.MustCompile("(.*?):.*")
rmsg.Username = re.ReplaceAllString(rmsg.Username, `$1`)
}
// Delete event
if ev.Type == "m.room.redaction" {
rmsg.Event = config.EventMsgDelete
rmsg.ID = ev.Redacts
rmsg.Text = config.EventMsgDelete
b.Remote <- rmsg
return
}
// Text must be a string
if rmsg.Text, ok = ev.Content["body"].(string); !ok {
b.Log.Errorf("Content[body] is not a string: %T\n%#v",
ev.Content["body"], ev.Content)
return
}
// Do we have a /me action
if ev.Content["msgtype"].(string) == "m.emote" {
rmsg.Event = config.EventUserAction
}
// Is it an edit?
if b.handleEdit(ev, rmsg) {
return
}
// Is it a reply?
if b.handleReply(ev, rmsg) {
return
}
// Do we have attachments
if b.containsAttachment(ev.Content) {
err := b.handleDownloadFile(&rmsg, ev.Content)
if err != nil {
b.Log.Errorf("download failed: %#v", err)
}
}
b.Log.Debugf("<= Sending message from %s on %s to gateway", ev.Sender, b.Account)
b.Remote <- rmsg
// not crucial, so no ratelimit check here
if err := b.mc.MarkRead(ev.RoomID, ev.ID); err != nil {
b.Log.Errorf("couldn't mark message as read %s", err.Error())
}
}
}
// handleDownloadFile handles file download
func (b *Bmatrix) handleDownloadFile(rmsg *config.Message, content map[string]interface{}) error {
var (
ok bool
url, name, msgtype, mtype string
info map[string]interface{}
size float64
)
rmsg.Extra = make(map[string][]interface{})
if url, ok = content["url"].(string); !ok {
return fmt.Errorf("url isn't a %T", url)
}
url = strings.Replace(url, "mxc://", b.GetString("Server")+"/_matrix/media/v1/download/", -1)
if info, ok = content["info"].(map[string]interface{}); !ok {
return fmt.Errorf("info isn't a %T", info)
}
if size, ok = info["size"].(float64); !ok {
return fmt.Errorf("size isn't a %T", size)
}
if name, ok = content["body"].(string); !ok {
return fmt.Errorf("name isn't a %T", name)
}
if msgtype, ok = content["msgtype"].(string); !ok {
return fmt.Errorf("msgtype isn't a %T", msgtype)
}
if mtype, ok = info["mimetype"].(string); !ok {
return fmt.Errorf("mtype isn't a %T", mtype)
}
// check if we have an image uploaded without extension
if !strings.Contains(name, ".") {
if msgtype == "m.image" {
mext, _ := mime.ExtensionsByType(mtype)
if len(mext) > 0 {
name += mext[0]
}
} else {
// just a default .png extension if we don't have mime info
name += ".png"
}
}
// check if the size is ok
err := helper.HandleDownloadSize(b.Log, rmsg, name, int64(size), b.General)
if err != nil {
return err
}
// actually download the file
data, err := helper.DownloadFile(url)
if err != nil {
return fmt.Errorf("download %s failed %#v", url, err)
}
// add the downloaded data to the message
helper.HandleDownloadData(b.Log, rmsg, name, "", url, data, b.General)
return nil
}
// handleUploadFiles handles native upload of files.
func (b *Bmatrix) handleUploadFiles(msg *config.Message, channel string) (string, error) {
for _, f := range msg.Extra["file"] {
if fi, ok := f.(config.FileInfo); ok {
b.handleUploadFile(msg, channel, &fi)
}
}
return "", nil
}
// handleUploadFile handles native upload of a file.
func (b *Bmatrix) handleUploadFile(msg *config.Message, channel string, fi *config.FileInfo) {
username := newMatrixUsername(msg.Username)
content := bytes.NewReader(*fi.Data)
sp := strings.Split(fi.Name, ".")
mtype := mime.TypeByExtension("." + sp[len(sp)-1])
// image and video uploads send no username, we have to do this ourself here #715
err := b.retry(func() error {
_, err := b.mc.SendFormattedText(channel, username.plain+fi.Comment, username.formatted+fi.Comment)
return err
})
if err != nil {
b.Log.Errorf("file comment failed: %#v", err)
}
b.Log.Debugf("uploading file: %s %s", fi.Name, mtype)
var res *matrix.RespMediaUpload
err = b.retry(func() error {
res, err = b.mc.UploadToContentRepo(content, mtype, int64(len(*fi.Data)))
return err
})
if err != nil {
b.Log.Errorf("file upload failed: %#v", err)
return
}
switch {
case strings.Contains(mtype, "video"):
b.Log.Debugf("sendVideo %s", res.ContentURI)
err = b.retry(func() error {
_, err = b.mc.SendVideo(channel, fi.Name, res.ContentURI)
return err
})
if err != nil {
b.Log.Errorf("sendVideo failed: %#v", err)
}
case strings.Contains(mtype, "image"):
b.Log.Debugf("sendImage %s", res.ContentURI)
err = b.retry(func() error {
_, err = b.mc.SendImage(channel, fi.Name, res.ContentURI)
return err
})
if err != nil {
b.Log.Errorf("sendImage failed: %#v", err)
}
case strings.Contains(mtype, "audio"):
b.Log.Debugf("sendAudio %s", res.ContentURI)
err = b.retry(func() error {
_, err = b.mc.SendMessageEvent(channel, "m.room.message", matrix.AudioMessage{
MsgType: "m.audio",
Body: fi.Name,
URL: res.ContentURI,
Info: matrix.AudioInfo{
Mimetype: mtype,
Size: uint(len(*fi.Data)),
},
})
return err
})
if err != nil {
b.Log.Errorf("sendAudio failed: %#v", err)
}
default:
b.Log.Debugf("sendFile %s", res.ContentURI)
err = b.retry(func() error {
_, err = b.mc.SendMessageEvent(channel, "m.room.message", matrix.FileMessage{
MsgType: "m.file",
Body: fi.Name,
URL: res.ContentURI,
Info: matrix.FileInfo{
Mimetype: mtype,
Size: uint(len(*fi.Data)),
},
})
return err
})
if err != nil {
b.Log.Errorf("sendFile failed: %#v", err)
}
}
b.Log.Debugf("result: %#v", res)
}

View File

@ -1,28 +0,0 @@
package bmatrix
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPlainUsername(t *testing.T) {
uut := newMatrixUsername("MyUser")
assert.Equal(t, "MyUser", uut.formatted)
assert.Equal(t, "MyUser", uut.plain)
}
func TestHTMLUsername(t *testing.T) {
uut := newMatrixUsername("<b>MyUser</b>")
assert.Equal(t, "<b>MyUser</b>", uut.formatted)
assert.Equal(t, "MyUser", uut.plain)
}
func TestFancyUsername(t *testing.T) {
uut := newMatrixUsername("<MyUser>")
assert.Equal(t, "&lt;MyUser&gt;", uut.formatted)
assert.Equal(t, "<MyUser>", uut.plain)
}

View File

@ -1,351 +0,0 @@
package bmattermost
import (
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/42wim/matterbridge/matterclient"
matterclient6 "github.com/matterbridge/matterclient"
"github.com/mattermost/mattermost-server/v5/model"
model6 "github.com/mattermost/mattermost-server/v6/model"
)
// handleDownloadAvatar downloads the avatar of userid from channel
// sends a EVENT_AVATAR_DOWNLOAD message to the gateway if successful.
// logs an error message if it fails
func (b *Bmattermost) handleDownloadAvatar(userid string, channel string) {
rmsg := config.Message{
Username: "system",
Text: "avatar",
Channel: channel,
Account: b.Account,
UserID: userid,
Event: config.EventAvatarDownload,
Extra: make(map[string][]interface{}),
}
if _, ok := b.avatarMap[userid]; !ok {
var (
data []byte
err error
resp *model.Response
)
if b.mc6 != nil {
data, _, err = b.mc6.Client.GetProfileImage(userid, "")
if err != nil {
b.Log.Errorf("ProfileImage download failed for %#v %s", userid, err)
return
}
} else {
data, resp = b.mc.Client.GetProfileImage(userid, "")
if resp.Error != nil {
b.Log.Errorf("ProfileImage download failed for %#v %s", userid, resp.Error)
return
}
}
err = helper.HandleDownloadSize(b.Log, &rmsg, userid+".png", int64(len(data)), b.General)
if err != nil {
b.Log.Error(err)
return
}
helper.HandleDownloadData(b.Log, &rmsg, userid+".png", rmsg.Text, "", &data, b.General)
b.Remote <- rmsg
}
}
// handleDownloadFile handles file download
func (b *Bmattermost) handleDownloadFile(rmsg *config.Message, id string) error {
if b.mc6 != nil {
return b.handleDownloadFile6(rmsg, id)
}
url, _ := b.mc.Client.GetFileLink(id)
finfo, resp := b.mc.Client.GetFileInfo(id)
if resp.Error != nil {
return resp.Error
}
err := helper.HandleDownloadSize(b.Log, rmsg, finfo.Name, finfo.Size, b.General)
if err != nil {
return err
}
data, resp := b.mc.Client.DownloadFile(id, true)
if resp.Error != nil {
return resp.Error
}
helper.HandleDownloadData(b.Log, rmsg, finfo.Name, rmsg.Text, url, &data, b.General)
return nil
}
// nolint:wrapcheck
func (b *Bmattermost) handleDownloadFile6(rmsg *config.Message, id string) error {
url, _, _ := b.mc6.Client.GetFileLink(id)
finfo, _, err := b.mc6.Client.GetFileInfo(id)
if err != nil {
return err
}
err = helper.HandleDownloadSize(b.Log, rmsg, finfo.Name, finfo.Size, b.General)
if err != nil {
return err
}
data, _, err := b.mc6.Client.DownloadFile(id, true)
if err != nil {
return err
}
helper.HandleDownloadData(b.Log, rmsg, finfo.Name, rmsg.Text, url, &data, b.General)
return nil
}
func (b *Bmattermost) handleMatter() {
messages := make(chan *config.Message)
if b.GetString("WebhookBindAddress") != "" {
b.Log.Debugf("Choosing webhooks based receiving")
go b.handleMatterHook(messages)
} else {
if b.GetString("Token") != "" {
b.Log.Debugf("Choosing token based receiving")
} else {
b.Log.Debugf("Choosing login/password based receiving")
}
// if for some reason we only want to sent stuff to mattermost but not receive, return
if b.GetString("WebhookBindAddress") == "" && b.GetString("WebhookURL") != "" && b.GetString("Token") == "" && b.GetString("Login") == "" {
b.Log.Debugf("No WebhookBindAddress specified, only WebhookURL. You will not receive messages from mattermost, only sending is possible.")
}
go b.handleMatterClient(messages)
}
var ok bool
for message := range messages {
message.Avatar = helper.GetAvatar(b.avatarMap, message.UserID, b.General)
message.Account = b.Account
message.Text, ok = b.replaceAction(message.Text)
if ok {
message.Event = config.EventUserAction
}
b.Log.Debugf("<= Sending message from %s on %s to gateway", message.Username, b.Account)
b.Log.Debugf("<= Message is %#v", message)
b.Remote <- *message
}
}
func (b *Bmattermost) handleMatterClient(messages chan *config.Message) {
if b.mc6 != nil {
b.Log.Debug("starting matterclient6")
b.handleMatterClient6(messages)
return
}
for message := range b.mc.MessageChan {
b.Log.Debugf("%#v", message.Raw.Data)
if b.skipMessage(message) {
b.Log.Debugf("Skipped message: %#v", message)
continue
}
// only download avatars if we have a place to upload them (configured mediaserver)
if b.General.MediaServerUpload != "" || b.General.MediaDownloadPath != "" {
b.handleDownloadAvatar(message.UserID, message.Channel)
}
b.Log.Debugf("== Receiving event %#v", message)
rmsg := &config.Message{
Username: message.Username,
UserID: message.UserID,
Channel: message.Channel,
Text: message.Text,
ID: message.Post.Id,
ParentID: message.Post.RootId, // ParentID is obsolete with mattermost
Extra: make(map[string][]interface{}),
}
// handle mattermost post properties (override username and attachments)
b.handleProps(rmsg, message)
// create a text for bridges that don't support native editing
if message.Raw.Event == model.WEBSOCKET_EVENT_POST_EDITED && !b.GetBool("EditDisable") {
rmsg.Text = message.Text + b.GetString("EditSuffix")
}
if message.Raw.Event == model.WEBSOCKET_EVENT_POST_DELETED {
rmsg.Event = config.EventMsgDelete
}
for _, id := range message.Post.FileIds {
err := b.handleDownloadFile(rmsg, id)
if err != nil {
b.Log.Errorf("download failed: %s", err)
}
}
// Use nickname instead of username if defined
if nick := b.mc.GetNickName(rmsg.UserID); nick != "" {
rmsg.Username = nick
}
messages <- rmsg
}
}
// nolint:cyclop
func (b *Bmattermost) handleMatterClient6(messages chan *config.Message) {
for message := range b.mc6.MessageChan {
b.Log.Debugf("%#v %#v", message.Raw.GetData(), message.Raw.EventType())
if b.skipMessage6(message) {
b.Log.Debugf("Skipped message: %#v", message)
continue
}
// only download avatars if we have a place to upload them (configured mediaserver)
if b.General.MediaServerUpload != "" || b.General.MediaDownloadPath != "" {
b.handleDownloadAvatar(message.UserID, message.Channel)
}
b.Log.Debugf("== Receiving event %#v", message)
rmsg := &config.Message{
Username: message.Username,
UserID: message.UserID,
Channel: message.Channel,
Text: message.Text,
ID: message.Post.Id,
ParentID: message.Post.RootId, // ParentID is obsolete with mattermost
Extra: make(map[string][]interface{}),
}
// handle mattermost post properties (override username and attachments)
b.handleProps6(rmsg, message)
// create a text for bridges that don't support native editing
if message.Raw.EventType() == model6.WebsocketEventPostEdited && !b.GetBool("EditDisable") {
rmsg.Text = message.Text + b.GetString("EditSuffix")
}
if message.Raw.EventType() == model6.WebsocketEventPostDeleted {
rmsg.Event = config.EventMsgDelete
}
for _, id := range message.Post.FileIds {
err := b.handleDownloadFile(rmsg, id)
if err != nil {
b.Log.Errorf("download failed: %s", err)
}
}
// Use nickname instead of username if defined
if nick := b.mc6.GetNickName(rmsg.UserID); nick != "" {
rmsg.Username = nick
}
messages <- rmsg
}
}
func (b *Bmattermost) handleMatterHook(messages chan *config.Message) {
for {
message := b.mh.Receive()
b.Log.Debugf("Receiving from matterhook %#v", message)
messages <- &config.Message{
UserID: message.UserID,
Username: message.UserName,
Text: message.Text,
Channel: message.ChannelName,
}
}
}
// handleUploadFile handles native upload of files
func (b *Bmattermost) handleUploadFile(msg *config.Message) (string, error) {
if b.mc6 != nil {
return b.handleUploadFile6(msg)
}
var err error
var res, id string
channelID := b.mc.GetChannelId(msg.Channel, b.TeamID)
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
id, err = b.mc.UploadFile(*fi.Data, channelID, fi.Name)
if err != nil {
return "", err
}
msg.Text = fi.Comment
if b.GetBool("PrefixMessagesWithNick") {
msg.Text = msg.Username + msg.Text
}
res, err = b.mc.PostMessageWithFiles(channelID, msg.Text, msg.ParentID, []string{id})
}
return res, err
}
// nolint:forcetypeassert,wrapcheck
func (b *Bmattermost) handleUploadFile6(msg *config.Message) (string, error) {
var err error
var res, id string
channelID := b.mc6.GetChannelID(msg.Channel, b.TeamID)
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
id, err = b.mc6.UploadFile(*fi.Data, channelID, fi.Name)
if err != nil {
return "", err
}
msg.Text = fi.Comment
if b.GetBool("PrefixMessagesWithNick") {
msg.Text = msg.Username + msg.Text
}
res, err = b.mc6.PostMessageWithFiles(channelID, msg.Text, msg.ParentID, []string{id})
}
return res, err
}
func (b *Bmattermost) handleProps(rmsg *config.Message, message *matterclient.Message) {
props := message.Post.Props
if props == nil {
return
}
if _, ok := props["override_username"].(string); ok {
rmsg.Username = props["override_username"].(string)
}
if _, ok := props["attachments"].([]interface{}); ok {
rmsg.Extra["attachments"] = props["attachments"].([]interface{})
if rmsg.Text == "" {
for _, attachment := range rmsg.Extra["attachments"] {
attach := attachment.(map[string]interface{})
if attach["text"].(string) != "" {
rmsg.Text += attach["text"].(string)
continue
}
if attach["fallback"].(string) != "" {
rmsg.Text += attach["fallback"].(string)
}
}
}
}
}
// nolint:forcetypeassert
func (b *Bmattermost) handleProps6(rmsg *config.Message, message *matterclient6.Message) {
props := message.Post.Props
if props == nil {
return
}
if _, ok := props["override_username"].(string); ok {
rmsg.Username = props["override_username"].(string)
}
if _, ok := props["attachments"].([]interface{}); ok {
rmsg.Extra["attachments"] = props["attachments"].([]interface{})
if rmsg.Text != "" {
return
}
for _, attachment := range rmsg.Extra["attachments"] {
attach := attachment.(map[string]interface{})
if attach["text"].(string) != "" {
rmsg.Text += attach["text"].(string)
continue
}
if attach["fallback"].(string) != "" {
rmsg.Text += attach["fallback"].(string)
}
}
}
}

View File

@ -1,378 +0,0 @@
package bmattermost
import (
"net/http"
"strings"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/42wim/matterbridge/matterclient"
"github.com/42wim/matterbridge/matterhook"
matterclient6 "github.com/matterbridge/matterclient"
"github.com/mattermost/mattermost-server/v5/model"
model6 "github.com/mattermost/mattermost-server/v6/model"
)
func (b *Bmattermost) doConnectWebhookBind() error {
switch {
case b.GetString("WebhookURL") != "":
b.Log.Info("Connecting using webhookurl (sending) and webhookbindaddress (receiving)")
b.mh = matterhook.New(b.GetString("WebhookURL"),
matterhook.Config{
InsecureSkipVerify: b.GetBool("SkipTLSVerify"),
BindAddress: b.GetString("WebhookBindAddress"),
})
case b.GetString("Token") != "":
b.Log.Info("Connecting using token (sending)")
b.Log.Infof("Using mattermost v6 methods: %t", b.v6)
if b.v6 {
err := b.apiLogin6()
if err != nil {
return err
}
} else {
err := b.apiLogin()
if err != nil {
return err
}
}
case b.GetString("Login") != "":
b.Log.Info("Connecting using login/password (sending)")
b.Log.Infof("Using mattermost v6 methods: %t", b.v6)
if b.v6 {
err := b.apiLogin6()
if err != nil {
return err
}
} else {
err := b.apiLogin()
if err != nil {
return err
}
}
default:
b.Log.Info("Connecting using webhookbindaddress (receiving)")
b.mh = matterhook.New(b.GetString("WebhookURL"),
matterhook.Config{
InsecureSkipVerify: b.GetBool("SkipTLSVerify"),
BindAddress: b.GetString("WebhookBindAddress"),
})
}
return nil
}
func (b *Bmattermost) doConnectWebhookURL() error {
b.Log.Info("Connecting using webhookurl (sending)")
b.mh = matterhook.New(b.GetString("WebhookURL"),
matterhook.Config{
InsecureSkipVerify: b.GetBool("SkipTLSVerify"),
DisableServer: true,
})
if b.GetString("Token") != "" {
b.Log.Info("Connecting using token (receiving)")
b.Log.Infof("Using mattermost v6 methods: %t", b.v6)
if b.v6 {
err := b.apiLogin6()
if err != nil {
return err
}
} else {
err := b.apiLogin()
if err != nil {
return err
}
}
} else if b.GetString("Login") != "" {
b.Log.Info("Connecting using login/password (receiving)")
b.Log.Infof("Using mattermost v6 methods: %t", b.v6)
if b.v6 {
err := b.apiLogin6()
if err != nil {
return err
}
} else {
err := b.apiLogin()
if err != nil {
return err
}
}
}
return nil
}
func (b *Bmattermost) apiLogin() error {
password := b.GetString("Password")
if b.GetString("Token") != "" {
password = "token=" + b.GetString("Token")
}
b.mc = matterclient.New(b.GetString("Login"), password, b.GetString("Team"), b.GetString("Server"))
if b.GetBool("debug") {
b.mc.SetLogLevel("debug")
}
b.mc.SkipTLSVerify = b.GetBool("SkipTLSVerify")
b.mc.SkipVersionCheck = b.GetBool("SkipVersionCheck")
b.mc.NoTLS = b.GetBool("NoTLS")
b.Log.Infof("Connecting %s (team: %s) on %s", b.GetString("Login"), b.GetString("Team"), b.GetString("Server"))
err := b.mc.Login()
if err != nil {
return err
}
b.Log.Info("Connection succeeded")
b.TeamID = b.mc.GetTeamId()
go b.mc.WsReceiver()
go b.mc.StatusLoop()
return nil
}
// nolint:wrapcheck
func (b *Bmattermost) apiLogin6() error {
password := b.GetString("Password")
if b.GetString("Token") != "" {
password = "token=" + b.GetString("Token")
}
b.mc6 = matterclient6.New(b.GetString("Login"), password, b.GetString("Team"), b.GetString("Server"), "")
if b.GetBool("debug") {
b.mc6.SetLogLevel("debug")
}
b.mc6.SkipTLSVerify = b.GetBool("SkipTLSVerify")
b.mc6.SkipVersionCheck = b.GetBool("SkipVersionCheck")
b.mc6.NoTLS = b.GetBool("NoTLS")
b.Log.Infof("Connecting %s (team: %s) on %s", b.GetString("Login"), b.GetString("Team"), b.GetString("Server"))
if err := b.mc6.Login(); err != nil {
return err
}
b.Log.Info("Connection succeeded")
b.TeamID = b.mc6.GetTeamID()
return nil
}
// replaceAction replace the message with the correct action (/me) code
func (b *Bmattermost) replaceAction(text string) (string, bool) {
if strings.HasPrefix(text, "*") && strings.HasSuffix(text, "*") {
return strings.Replace(text, "*", "", -1), true
}
return text, false
}
func (b *Bmattermost) cacheAvatar(msg *config.Message) (string, error) {
fi := msg.Extra["file"][0].(config.FileInfo)
/* if we have a sha we have successfully uploaded the file to the media server,
so we can now cache the sha */
if fi.SHA != "" {
b.Log.Debugf("Added %s to %s in avatarMap", fi.SHA, msg.UserID)
b.avatarMap[msg.UserID] = fi.SHA
}
return "", nil
}
// sendWebhook uses the configured WebhookURL to send the message
func (b *Bmattermost) sendWebhook(msg config.Message) (string, error) {
// skip events
if msg.Event != "" {
return "", nil
}
if b.GetBool("PrefixMessagesWithNick") {
msg.Text = msg.Username + msg.Text
}
if msg.Extra != nil {
// this sends a message only if we received a config.EVENT_FILE_FAILURE_SIZE
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
rmsg := rmsg // scopelint
iconURL := config.GetIconURL(&rmsg, b.GetString("iconurl"))
matterMessage := matterhook.OMessage{
IconURL: iconURL,
Channel: rmsg.Channel,
UserName: rmsg.Username,
Text: rmsg.Text,
Props: make(map[string]interface{}),
}
matterMessage.Props["matterbridge_"+b.uuid] = true
if err := b.mh.Send(matterMessage); err != nil {
b.Log.Errorf("sendWebhook failed: %s ", err)
}
}
// webhook doesn't support file uploads, so we add the url manually
if len(msg.Extra["file"]) > 0 {
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
if fi.URL != "" {
msg.Text += fi.URL
}
}
}
}
iconURL := config.GetIconURL(&msg, b.GetString("iconurl"))
matterMessage := matterhook.OMessage{
IconURL: iconURL,
Channel: msg.Channel,
UserName: msg.Username,
Text: msg.Text,
Props: make(map[string]interface{}),
}
if msg.Avatar != "" {
matterMessage.IconURL = msg.Avatar
}
matterMessage.Props["matterbridge_"+b.uuid] = true
err := b.mh.Send(matterMessage)
if err != nil {
b.Log.Info(err)
return "", err
}
return "", nil
}
// skipMessages returns true if this message should not be handled
func (b *Bmattermost) skipMessage(message *matterclient.Message) bool {
// Handle join/leave
if message.Type == "system_join_leave" ||
message.Type == "system_join_channel" ||
message.Type == "system_leave_channel" {
if b.GetBool("nosendjoinpart") {
return true
}
b.Log.Debugf("Sending JOIN_LEAVE event from %s to gateway", b.Account)
b.Remote <- config.Message{
Username: "system",
Text: message.Text,
Channel: message.Channel,
Account: b.Account,
Event: config.EventJoinLeave,
}
return true
}
// Handle edited messages
if (message.Raw.Event == model.WEBSOCKET_EVENT_POST_EDITED) && b.GetBool("EditDisable") {
return true
}
// Ignore non-post messages
if message.Post == nil {
b.Log.Debugf("ignoring nil message.Post: %#v", message)
return true
}
// Ignore messages sent from matterbridge
if message.Post.Props != nil {
if _, ok := message.Post.Props["matterbridge_"+b.uuid].(bool); ok {
b.Log.Debugf("sent by matterbridge, ignoring")
return true
}
}
// Ignore messages sent from a user logged in as the bot
if b.mc.User.Username == message.Username {
return true
}
// if the message has reactions don't repost it (for now, until we can correlate reaction with message)
if message.Post.HasReactions {
return true
}
// ignore messages from other teams than ours
if message.Raw.Data["team_id"].(string) != b.TeamID {
return true
}
// only handle posted, edited or deleted events
if !(message.Raw.Event == "posted" || message.Raw.Event == model.WEBSOCKET_EVENT_POST_EDITED ||
message.Raw.Event == model.WEBSOCKET_EVENT_POST_DELETED) {
return true
}
return false
}
// skipMessages returns true if this message should not be handled
// nolint:gocyclo,cyclop
func (b *Bmattermost) skipMessage6(message *matterclient6.Message) bool {
// Handle join/leave
if message.Type == "system_join_leave" ||
message.Type == "system_join_channel" ||
message.Type == "system_leave_channel" {
if b.GetBool("nosendjoinpart") {
return true
}
b.Log.Debugf("Sending JOIN_LEAVE event from %s to gateway", b.Account)
b.Remote <- config.Message{
Username: "system",
Text: message.Text,
Channel: message.Channel,
Account: b.Account,
Event: config.EventJoinLeave,
}
return true
}
// Handle edited messages
if (message.Raw.EventType() == model6.WebsocketEventPostEdited) && b.GetBool("EditDisable") {
return true
}
// Ignore non-post messages
if message.Post == nil {
b.Log.Debugf("ignoring nil message.Post: %#v", message)
return true
}
// Ignore messages sent from matterbridge
if message.Post.Props != nil {
if _, ok := message.Post.Props["matterbridge_"+b.uuid].(bool); ok {
b.Log.Debug("sent by matterbridge, ignoring")
return true
}
}
// Ignore messages sent from a user logged in as the bot
if b.mc6.User.Username == message.Username {
b.Log.Debug("message from same user as bot, ignoring")
return true
}
// if the message has reactions don't repost it (for now, until we can correlate reaction with message)
if message.Post.HasReactions {
return true
}
// ignore messages from other teams than ours
if message.Raw.GetData()["team_id"].(string) != b.TeamID {
b.Log.Debug("message from other team, ignoring")
return true
}
// only handle posted, edited or deleted events
if !(message.Raw.EventType() == "posted" || message.Raw.EventType() == model6.WebsocketEventPostEdited ||
message.Raw.EventType() == model6.WebsocketEventPostDeleted) {
return true
}
return false
}
func (b *Bmattermost) getVersion() string {
proto := "https"
if b.GetBool("notls") {
proto = "http"
}
resp, err := http.Get(proto + "://" + b.GetString("server"))
if err != nil {
b.Log.Error("failed getting version")
return ""
}
defer resp.Body.Close()
return resp.Header.Get("X-Version-Id")
}

View File

@ -1,38 +1,54 @@
package bmattermost
import (
"errors"
"fmt"
"strings"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/42wim/matterbridge/matterclient"
"github.com/42wim/matterbridge/matterhook"
matterclient6 "github.com/matterbridge/matterclient"
"github.com/rs/xid"
log "github.com/Sirupsen/logrus"
)
type Bmattermost struct {
mh *matterhook.Client
mc *matterclient.MMClient
mc6 *matterclient6.Client
v6 bool
uuid string
TeamID string
*bridge.Config
avatarMap map[string]string
type MMhook struct {
mh *matterhook.Client
}
const mattermostPlugin = "mattermost.plugin"
type MMapi struct {
mc *matterclient.MMClient
mmMap map[string]string
mmIgnoreNicks []string
}
func New(cfg *bridge.Config) bridge.Bridger {
b := &Bmattermost{Config: cfg, avatarMap: make(map[string]string)}
type MMMessage struct {
Text string
Channel string
Username string
}
b.v6 = b.GetBool("v6")
b.uuid = xid.New().String()
type Bmattermost struct {
MMhook
MMapi
Config *config.Protocol
Remote chan config.Message
name string
origin string
protocol string
TeamId string
}
var flog *log.Entry
var protocol = "mattermost"
func init() {
flog = log.WithFields(log.Fields{"module": protocol})
}
func New(cfg config.Protocol, origin string, c chan config.Message) *Bmattermost {
b := &Bmattermost{}
b.Config = &cfg
b.origin = origin
b.Remote = c
b.protocol = "mattermost"
b.name = cfg.Name
b.mmMap = make(map[string]string)
return b
}
@ -41,195 +57,126 @@ func (b *Bmattermost) Command(cmd string) string {
}
func (b *Bmattermost) Connect() error {
if b.Account == mattermostPlugin {
return nil
}
if strings.HasPrefix(b.getVersion(), "6.") {
if !b.v6 {
b.v6 = true
}
}
if b.GetString("WebhookBindAddress") != "" {
if err := b.doConnectWebhookBind(); err != nil {
if !b.Config.UseAPI {
flog.Info("Connecting webhooks")
b.mh = matterhook.New(b.Config.URL,
matterhook.Config{InsecureSkipVerify: b.Config.SkipTLSVerify,
BindAddress: b.Config.BindAddress})
} else {
b.mc = matterclient.New(b.Config.Login, b.Config.Password,
b.Config.Team, b.Config.Server)
b.mc.SkipTLSVerify = b.Config.SkipTLSVerify
b.mc.NoTLS = b.Config.NoTLS
flog.Infof("Connecting %s (team: %s) on %s", b.Config.Login, b.Config.Team, b.Config.Server)
err := b.mc.Login()
if err != nil {
return err
}
go b.handleMatter()
return nil
}
switch {
case b.GetString("WebhookURL") != "":
if err := b.doConnectWebhookURL(); err != nil {
return err
}
go b.handleMatter()
return nil
case b.GetString("Token") != "":
b.Log.Info("Connecting using token (sending and receiving)")
b.Log.Infof("Using mattermost v6 methods: %t", b.v6)
if b.v6 {
err := b.apiLogin6()
if err != nil {
return err
}
} else {
err := b.apiLogin()
if err != nil {
return err
}
}
go b.handleMatter()
case b.GetString("Login") != "":
b.Log.Info("Connecting using login/password (sending and receiving)")
b.Log.Infof("Using mattermost v6 methods: %t", b.v6)
if b.v6 {
err := b.apiLogin6()
if err != nil {
return err
}
} else {
err := b.apiLogin()
if err != nil {
return err
}
}
go b.handleMatter()
}
if b.GetString("WebhookBindAddress") == "" && b.GetString("WebhookURL") == "" &&
b.GetString("Login") == "" && b.GetString("Token") == "" {
return errors.New("no connection method found. See that you have WebhookBindAddress, WebhookURL or Token/Login/Password/Server/Team configured")
flog.Info("Connection succeeded")
b.TeamId = b.mc.GetTeamId()
go b.mc.WsReceiver()
}
go b.handleMatter()
return nil
}
func (b *Bmattermost) Disconnect() error {
return nil
func (b *Bmattermost) FullOrigin() string {
return b.protocol + "." + b.origin
}
func (b *Bmattermost) JoinChannel(channel config.ChannelInfo) error {
if b.Account == mattermostPlugin {
return nil
}
func (b *Bmattermost) JoinChannel(channel string) error {
// we can only join channels using the API
if b.GetString("WebhookURL") == "" && b.GetString("WebhookBindAddress") == "" {
var id string
if b.mc6 != nil {
id = b.mc6.GetChannelID(channel.Name, b.TeamID)
} else {
id = b.mc.GetChannelId(channel.Name, b.TeamID)
}
if id == "" {
return fmt.Errorf("Could not find channel ID for channel %s", channel.Name)
}
if b.mc6 != nil {
return b.mc6.JoinChannel(id) // nolint:wrapcheck
}
return b.mc.JoinChannel(id)
if b.Config.UseAPI {
return b.mc.JoinChannel(b.mc.GetChannelId(channel, ""))
}
return nil
}
func (b *Bmattermost) Send(msg config.Message) (string, error) {
if b.Account == mattermostPlugin {
return "", nil
}
b.Log.Debugf("=> Receiving %#v", msg)
// Make a action /me of the message
if msg.Event == config.EventUserAction {
msg.Text = "*" + msg.Text + "*"
}
// map the file SHA to our user (caches the avatar)
if msg.Event == config.EventAvatarDownload {
return b.cacheAvatar(&msg)
}
// Use webhook to send the message
if b.GetString("WebhookURL") != "" {
return b.sendWebhook(msg)
}
// Delete message
if msg.Event == config.EventMsgDelete {
if msg.ID == "" {
return "", nil
}
if b.mc6 != nil {
return msg.ID, b.mc6.DeleteMessage(msg.ID) // nolint:wrapcheck
}
return msg.ID, b.mc.DeleteMessage(msg.ID)
}
// Handle prefix hint for unthreaded messages.
if msg.ParentNotFound() {
msg.ParentID = ""
msg.Text = fmt.Sprintf("[thread]: %s", msg.Text)
}
// we only can reply to the root of the thread, not to a specific ID (like discord for example does)
if msg.ParentID != "" {
if b.mc6 != nil {
post, _, err := b.mc6.Client.GetPost(msg.ParentID, "")
if err != nil {
b.Log.Errorf("getting post %s failed: %s", msg.ParentID, err)
}
if post.RootId != "" {
msg.ParentID = post.RootId
}
} else {
post, res := b.mc.Client.GetPost(msg.ParentID, "")
if res.Error != nil {
b.Log.Errorf("getting post %s failed: %s", msg.ParentID, res.Error.DetailedError)
}
if post.RootId != "" {
msg.ParentID = post.RootId
}
}
}
// Upload a file if it exists
if msg.Extra != nil {
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
if b.mc6 != nil {
if _, err := b.mc6.PostMessage(b.mc.GetChannelId(rmsg.Channel, b.TeamID), rmsg.Username+rmsg.Text, msg.ParentID); err != nil {
b.Log.Errorf("PostMessage failed: %s", err)
}
} else {
if _, err := b.mc.PostMessage(b.mc.GetChannelId(rmsg.Channel, b.TeamID), rmsg.Username+rmsg.Text, msg.ParentID); err != nil {
b.Log.Errorf("PostMessage failed: %s", err)
}
}
}
if len(msg.Extra["file"]) > 0 {
return b.handleUploadFile(&msg)
}
}
// Prepend nick if configured
if b.GetBool("PrefixMessagesWithNick") {
msg.Text = msg.Username + msg.Text
}
// Edit message if we have an ID
if msg.ID != "" {
if b.mc6 != nil {
return b.mc6.EditMessage(msg.ID, msg.Text) // nolint:wrapcheck
}
return b.mc.EditMessage(msg.ID, msg.Text)
}
// Post normal message
if b.mc6 != nil {
return b.mc6.PostMessage(b.mc6.GetChannelID(msg.Channel, b.TeamID), msg.Text, msg.ParentID) // nolint:wrapcheck
}
return b.mc.PostMessage(b.mc.GetChannelId(msg.Channel, b.TeamID), msg.Text, msg.ParentID)
func (b *Bmattermost) Name() string {
return b.protocol + "." + b.origin
}
func (b *Bmattermost) Origin() string {
return b.origin
}
func (b *Bmattermost) Protocol() string {
return b.protocol
}
func (b *Bmattermost) Send(msg config.Message) error {
flog.Debugf("Receiving %#v", msg)
nick := config.GetNick(&msg, b.Config)
message := msg.Text
channel := msg.Channel
if b.Config.PrefixMessagesWithNick {
/*if IsMarkup(message) {
message = nick + "\n\n" + message
} else {
*/
message = nick + " " + message
//}
}
if !b.Config.UseAPI {
matterMessage := matterhook.OMessage{IconURL: b.Config.IconURL}
matterMessage.Channel = channel
matterMessage.UserName = nick
matterMessage.Type = ""
matterMessage.Text = message
err := b.mh.Send(matterMessage)
if err != nil {
flog.Info(err)
return err
}
return nil
}
b.mc.PostMessage(b.mc.GetChannelId(channel, ""), message)
return nil
}
func (b *Bmattermost) handleMatter() {
flog.Debugf("Choosing API based Mattermost connection: %t", b.Config.UseAPI)
mchan := make(chan *MMMessage)
if b.Config.UseAPI {
go b.handleMatterClient(mchan)
} else {
go b.handleMatterHook(mchan)
}
for message := range mchan {
flog.Debugf("Sending message from %s on %s to gateway", message.Username, b.FullOrigin())
b.Remote <- config.Message{Text: message.Text, Username: message.Username, Channel: message.Channel, Origin: b.origin, Protocol: b.protocol, FullOrigin: b.FullOrigin()}
}
}
func (b *Bmattermost) handleMatterClient(mchan chan *MMMessage) {
for message := range b.mc.MessageChan {
// do not post our own messages back to irc
// only listen to message from our team
if message.Raw.Event == "posted" && b.mc.User.Username != message.Username && message.Raw.TeamId == b.TeamId {
flog.Debugf("Receiving from matterclient %#v", message)
m := &MMMessage{}
m.Username = message.Username
m.Channel = message.Channel
m.Text = message.Text
if len(message.Post.Filenames) > 0 {
for _, link := range b.mc.GetPublicLinks(message.Post.Filenames) {
m.Text = m.Text + "\n" + link
}
}
mchan <- m
}
}
}
func (b *Bmattermost) handleMatterHook(mchan chan *MMMessage) {
for {
message := b.mh.Receive()
flog.Debugf("Receiving from matterhook %#v", message)
m := &MMMessage{}
m.Username = message.UserName
m.Text = message.Text
m.Channel = message.ChannelName
mchan <- m
}
}

View File

@ -1,101 +0,0 @@
package bmsteams
import (
"encoding/json"
"fmt"
"io/ioutil"
"strings"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
msgraph "github.com/yaegashi/msgraph.go/beta"
)
func (b *Bmsteams) findFile(weburl string) (string, error) {
itemRB, err := b.gc.GetDriveItemByURL(b.ctx, weburl)
if err != nil {
return "", err
}
itemRB.Workbook().Worksheets()
b.gc.Workbooks()
item, err := itemRB.Request().Get(b.ctx)
if err != nil {
return "", err
}
if url, ok := item.GetAdditionalData("@microsoft.graph.downloadUrl"); ok {
return url.(string), nil
}
return "", nil
}
// handleDownloadFile handles file download
func (b *Bmsteams) handleDownloadFile(rmsg *config.Message, filename, weburl string) error {
realURL, err := b.findFile(weburl)
if err != nil {
return err
}
// Actually download the file.
data, err := helper.DownloadFile(realURL)
if err != nil {
return fmt.Errorf("download %s failed %#v", weburl, err)
}
// If a comment is attached to the file(s) it is in the 'Text' field of the teams messge event
// and should be added as comment to only one of the files. We reset the 'Text' field to ensure
// that the comment is not duplicated.
comment := rmsg.Text
rmsg.Text = ""
helper.HandleDownloadData(b.Log, rmsg, filename, comment, weburl, data, b.General)
return nil
}
func (b *Bmsteams) handleAttachments(rmsg *config.Message, msg msgraph.ChatMessage) {
for _, a := range msg.Attachments {
//remove the attachment tags from the text
rmsg.Text = attachRE.ReplaceAllString(rmsg.Text, "")
//handle a code snippet (code block)
if *a.ContentType == "application/vnd.microsoft.card.codesnippet" {
b.handleCodeSnippet(rmsg, a)
continue
}
//handle the download
err := b.handleDownloadFile(rmsg, *a.Name, *a.ContentURL)
if err != nil {
b.Log.Errorf("download of %s failed: %s", *a.Name, err)
}
}
}
type AttachContent struct {
Language string `json:"language"`
CodeSnippetURL string `json:"codeSnippetUrl"`
}
func (b *Bmsteams) handleCodeSnippet(rmsg *config.Message, attach msgraph.ChatMessageAttachment) {
var content AttachContent
err := json.Unmarshal([]byte(*attach.Content), &content)
if err != nil {
b.Log.Errorf("unmarshal codesnippet failed: %s", err)
return
}
s := strings.Split(content.CodeSnippetURL, "/")
if len(s) != 13 {
b.Log.Errorf("codesnippetUrl has unexpected size: %s", content.CodeSnippetURL)
return
}
resp, err := b.gc.Teams().Request().Client().Get(content.CodeSnippetURL)
if err != nil {
b.Log.Errorf("retrieving snippet content failed:%s", err)
return
}
defer resp.Body.Close()
res, err := ioutil.ReadAll(resp.Body)
if err != nil {
b.Log.Errorf("reading snippet data failed: %s", err)
return
}
rmsg.Text = rmsg.Text + "\n```" + content.Language + "\n" + string(res) + "\n```\n"
}

View File

@ -1,229 +0,0 @@
package bmsteams
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"time"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/davecgh/go-spew/spew"
"github.com/mattn/godown"
msgraph "github.com/yaegashi/msgraph.go/beta"
"github.com/yaegashi/msgraph.go/msauth"
"golang.org/x/oauth2"
)
var (
defaultScopes = []string{"openid", "profile", "offline_access", "Group.Read.All", "Group.ReadWrite.All"}
attachRE = regexp.MustCompile(`<attachment id=.*?attachment>`)
)
type Bmsteams struct {
gc *msgraph.GraphServiceRequestBuilder
ctx context.Context
botID string
*bridge.Config
}
func New(cfg *bridge.Config) bridge.Bridger {
return &Bmsteams{Config: cfg}
}
func (b *Bmsteams) Connect() error {
tokenCachePath := b.GetString("sessionFile")
if tokenCachePath == "" {
tokenCachePath = "msteams_session.json"
}
ctx := context.Background()
m := msauth.NewManager()
m.LoadFile(tokenCachePath) //nolint:errcheck
ts, err := m.DeviceAuthorizationGrant(ctx, b.GetString("TenantID"), b.GetString("ClientID"), defaultScopes, nil)
if err != nil {
return err
}
err = m.SaveFile(tokenCachePath)
if err != nil {
b.Log.Errorf("Couldn't save sessionfile in %s: %s", tokenCachePath, err)
}
// make file readable only for matterbridge user
err = os.Chmod(tokenCachePath, 0o600)
if err != nil {
b.Log.Errorf("Couldn't change permissions for %s: %s", tokenCachePath, err)
}
httpClient := oauth2.NewClient(ctx, ts)
graphClient := msgraph.NewClient(httpClient)
b.gc = graphClient
b.ctx = ctx
err = b.setBotID()
if err != nil {
return err
}
b.Log.Info("Connection succeeded")
return nil
}
func (b *Bmsteams) Disconnect() error {
return nil
}
func (b *Bmsteams) JoinChannel(channel config.ChannelInfo) error {
go func(name string) {
for {
err := b.poll(name)
if err != nil {
b.Log.Errorf("polling failed for %s: %s. retrying in 5 seconds", name, err)
}
time.Sleep(time.Second * 5)
}
}(channel.Name)
return nil
}
func (b *Bmsteams) Send(msg config.Message) (string, error) {
b.Log.Debugf("=> Receiving %#v", msg)
if msg.ParentValid() {
return b.sendReply(msg)
}
// Handle prefix hint for unthreaded messages.
if msg.ParentNotFound() {
msg.ParentID = ""
msg.Text = fmt.Sprintf("[thread]: %s", msg.Text)
}
ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(msg.Channel).Messages().Request()
text := msg.Username + msg.Text
content := &msgraph.ItemBody{Content: &text}
rmsg := &msgraph.ChatMessage{Body: content}
res, err := ct.Add(b.ctx, rmsg)
if err != nil {
return "", err
}
return *res.ID, nil
}
func (b *Bmsteams) sendReply(msg config.Message) (string, error) {
ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(msg.Channel).Messages().ID(msg.ParentID).Replies().Request()
// Handle prefix hint for unthreaded messages.
text := msg.Username + msg.Text
content := &msgraph.ItemBody{Content: &text}
rmsg := &msgraph.ChatMessage{Body: content}
res, err := ct.Add(b.ctx, rmsg)
if err != nil {
return "", err
}
return *res.ID, nil
}
func (b *Bmsteams) getMessages(channel string) ([]msgraph.ChatMessage, error) {
ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(channel).Messages().Request()
rct, err := ct.Get(b.ctx)
if err != nil {
return nil, err
}
b.Log.Debugf("got %#v messages", len(rct))
return rct, nil
}
//nolint:gocognit
func (b *Bmsteams) poll(channelName string) error {
msgmap := make(map[string]time.Time)
b.Log.Debug("getting initial messages")
res, err := b.getMessages(channelName)
if err != nil {
return err
}
for _, msg := range res {
msgmap[*msg.ID] = *msg.CreatedDateTime
if msg.LastModifiedDateTime != nil {
msgmap[*msg.ID] = *msg.LastModifiedDateTime
}
}
time.Sleep(time.Second * 5)
b.Log.Debug("polling for messages")
for {
res, err := b.getMessages(channelName)
if err != nil {
return err
}
for i := len(res) - 1; i >= 0; i-- {
msg := res[i]
if mtime, ok := msgmap[*msg.ID]; ok {
if mtime == *msg.CreatedDateTime && msg.LastModifiedDateTime == nil {
continue
}
if msg.LastModifiedDateTime != nil && mtime == *msg.LastModifiedDateTime {
continue
}
}
if b.GetBool("debug") {
b.Log.Debug("Msg dump: ", spew.Sdump(msg))
}
// skip non-user message for now.
if msg.From == nil || msg.From.User == nil {
continue
}
if *msg.From.User.ID == b.botID {
b.Log.Debug("skipping own message")
msgmap[*msg.ID] = *msg.CreatedDateTime
continue
}
msgmap[*msg.ID] = *msg.CreatedDateTime
if msg.LastModifiedDateTime != nil {
msgmap[*msg.ID] = *msg.LastModifiedDateTime
}
b.Log.Debugf("<= Sending message from %s on %s to gateway", *msg.From.User.DisplayName, b.Account)
text := b.convertToMD(*msg.Body.Content)
rmsg := config.Message{
Username: *msg.From.User.DisplayName,
Text: text,
Channel: channelName,
Account: b.Account,
Avatar: "",
UserID: *msg.From.User.ID,
ID: *msg.ID,
Extra: make(map[string][]interface{}),
}
b.handleAttachments(&rmsg, msg)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}
time.Sleep(time.Second * 5)
}
}
func (b *Bmsteams) setBotID() error {
req := b.gc.Me().Request()
r, err := req.Get(b.ctx)
if err != nil {
return err
}
b.botID = *r.ID
return nil
}
func (b *Bmsteams) convertToMD(text string) string {
if !strings.Contains(text, "<div>") {
return text
}
var sb strings.Builder
err := godown.Convert(&sb, strings.NewReader(text), nil)
if err != nil {
b.Log.Errorf("Couldn't convert message to markdown %s", text)
return text
}
return sb.String()
}

View File

@ -1,96 +0,0 @@
package bmumble
import (
"strconv"
"time"
"layeh.com/gumble/gumble"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
)
func (b *Bmumble) handleServerConfig(event *gumble.ServerConfigEvent) {
b.serverConfigUpdate <- *event
}
func (b *Bmumble) handleTextMessage(event *gumble.TextMessageEvent) {
sender := "unknown"
if event.TextMessage.Sender != nil {
sender = event.TextMessage.Sender.Name
}
// If the text message is received before receiving a ServerSync
// and UserState, Client.Self or Self.Channel are nil
if event.Client.Self == nil || event.Client.Self.Channel == nil {
b.Log.Warn("Connection bootstrap not finished, discarding text message")
return
}
// Convert Mumble HTML messages to markdown
parts, err := b.convertHTMLtoMarkdown(event.TextMessage.Message)
if err != nil {
b.Log.Error(err)
}
now := time.Now().UTC()
for i, part := range parts {
// Construct matterbridge message and pass on to the gateway
rmsg := config.Message{
Channel: strconv.FormatUint(uint64(event.Client.Self.Channel.ID), 10),
Username: sender,
UserID: sender + "@" + b.Host,
Account: b.Account,
}
if part.Image == nil {
rmsg.Text = part.Text
} else {
fname := b.Account + "_" + strconv.FormatInt(now.UnixNano(), 10) + "_" + strconv.Itoa(i) + part.FileExtension
rmsg.Extra = make(map[string][]interface{})
if err = helper.HandleDownloadSize(b.Log, &rmsg, fname, int64(len(part.Image)), b.General); err != nil {
b.Log.WithError(err).Warn("not including image in message")
continue
}
helper.HandleDownloadData(b.Log, &rmsg, fname, "", "", &part.Image, b.General)
}
b.Log.Debugf("Sending message to gateway: %+v", rmsg)
b.Remote <- rmsg
}
}
func (b *Bmumble) handleConnect(event *gumble.ConnectEvent) {
// Set the user's "bio"/comment
if comment := b.GetString("UserComment"); comment != "" && event.Client.Self != nil {
event.Client.Self.SetComment(comment)
}
// No need to talk or listen
event.Client.Self.SetSelfDeafened(true)
event.Client.Self.SetSelfMuted(true)
// if the Channel variable is set, this is a reconnect -> rejoin channel
if b.Channel != nil {
if err := b.doJoin(event.Client, *b.Channel); err != nil {
b.Log.Error(err)
}
b.Remote <- config.Message{
Username: "system",
Text: "rejoin",
Channel: "",
Account: b.Account,
Event: config.EventRejoinChannels,
}
}
}
func (b *Bmumble) handleUserChange(event *gumble.UserChangeEvent) {
// Only care about changes to self
if event.User != event.Client.Self {
return
}
// Someone attempted to move the user out of the configured channel; attempt to join back
if b.Channel != nil {
if err := b.doJoin(event.Client, *b.Channel); err != nil {
b.Log.Error(err)
}
}
}
func (b *Bmumble) handleDisconnect(event *gumble.DisconnectEvent) {
b.connected <- *event
}

View File

@ -1,143 +0,0 @@
package bmumble
import (
"fmt"
"mime"
"net/http"
"regexp"
"strings"
"github.com/42wim/matterbridge/bridge/config"
"github.com/mattn/godown"
"github.com/vincent-petithory/dataurl"
)
type MessagePart struct {
Text string
FileExtension string
Image []byte
}
func (b *Bmumble) decodeImage(uri string, parts *[]MessagePart) error {
// Decode the data:image/... URI
image, err := dataurl.DecodeString(uri)
if err != nil {
b.Log.WithError(err).Info("No image extracted")
return err
}
// Determine the file extensions for that image
ext, err := mime.ExtensionsByType(image.MediaType.ContentType())
if err != nil || len(ext) == 0 {
b.Log.WithError(err).Infof("No file extension registered for MIME type '%s'", image.MediaType.ContentType())
return err
}
// Add the image to the MessagePart slice
*parts = append(*parts, MessagePart{"", ext[0], image.Data})
return nil
}
func (b *Bmumble) tokenize(t *string) ([]MessagePart, error) {
// `^(.*?)` matches everything before the image
// `!\[[^\]]*\]\(` matches the `![alt](` part of markdown images
// `(data:image\/[^)]+)` matches the data: URI used by Mumble
// `\)` matches the closing parenthesis after the URI
// `(.*)$` matches the remaining text to be examined in the next iteration
p := regexp.MustCompile(`^(?ms)(.*?)!\[[^\]]*\]\((data:image\/[^)]+)\)(.*)$`)
remaining := *t
var parts []MessagePart
for {
tokens := p.FindStringSubmatch(remaining)
if tokens == nil {
// no match -> remaining string is non-image text
pre := strings.TrimSpace(remaining)
if len(pre) > 0 {
parts = append(parts, MessagePart{pre, "", nil})
}
return parts, nil
}
// tokens[1] is the text before the image
if len(tokens[1]) > 0 {
pre := strings.TrimSpace(tokens[1])
parts = append(parts, MessagePart{pre, "", nil})
}
// tokens[2] is the image URL
uri, err := dataurl.UnescapeToString(strings.TrimSpace(strings.ReplaceAll(tokens[2], " ", "")))
if err != nil {
b.Log.WithError(err).Info("URL unescaping failed")
remaining = strings.TrimSpace(tokens[3])
continue
}
err = b.decodeImage(uri, &parts)
if err != nil {
b.Log.WithError(err).Info("Decoding the image failed")
}
// tokens[3] is the text after the image, processed in the next iteration
remaining = strings.TrimSpace(tokens[3])
}
}
func (b *Bmumble) convertHTMLtoMarkdown(html string) ([]MessagePart, error) {
var sb strings.Builder
err := godown.Convert(&sb, strings.NewReader(html), nil)
if err != nil {
return nil, err
}
markdown := sb.String()
b.Log.Debugf("### to markdown: %s", markdown)
return b.tokenize(&markdown)
}
func (b *Bmumble) extractFiles(msg *config.Message) []config.Message {
var messages []config.Message
if msg.Extra == nil || len(msg.Extra["file"]) == 0 {
return messages
}
// Create a separate message for each file
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
imsg := config.Message{
Channel: msg.Channel,
Username: msg.Username,
UserID: msg.UserID,
Account: msg.Account,
Protocol: msg.Protocol,
Timestamp: msg.Timestamp,
Event: "mumble_image",
}
// If no data is present for the file, send a link instead
if fi.Data == nil || len(*fi.Data) == 0 {
if len(fi.URL) > 0 {
imsg.Text = fmt.Sprintf(`<a href="%s">%s</a>`, fi.URL, fi.URL)
messages = append(messages, imsg)
} else {
b.Log.Infof("Not forwarding file without local data")
}
continue
}
mimeType := http.DetectContentType(*fi.Data)
// Mumble only supports images natively, send a link instead
if !strings.HasPrefix(mimeType, "image/") {
if len(fi.URL) > 0 {
imsg.Text = fmt.Sprintf(`<a href="%s">%s</a>`, fi.URL, fi.URL)
messages = append(messages, imsg)
} else {
b.Log.Infof("Not forwarding file of type %s", mimeType)
}
continue
}
mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0])
// Build data:image/...;base64,... style image URL and embed image directly into the message
du := dataurl.New(*fi.Data, mimeType)
dataURL, err := du.MarshalText()
if err != nil {
b.Log.WithError(err).Infof("Image Serialization into data URL failed (type: %s, length: %d)", mimeType, len(*fi.Data))
continue
}
imsg.Text = fmt.Sprintf(`<img src="%s"/>`, dataURL)
messages = append(messages, imsg)
}
// Remove files from original message
msg.Extra["file"] = nil
return messages
}

View File

@ -1,262 +0,0 @@
package bmumble
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io/ioutil"
"net"
"strconv"
"strings"
"time"
"layeh.com/gumble/gumble"
"layeh.com/gumble/gumbleutil"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
stripmd "github.com/writeas/go-strip-markdown"
// We need to import the 'data' package as an implicit dependency.
// See: https://godoc.org/github.com/paulrosania/go-charset/charset
_ "github.com/paulrosania/go-charset/data"
)
type Bmumble struct {
client *gumble.Client
Nick string
Host string
Channel *uint32
local chan config.Message
running chan error
connected chan gumble.DisconnectEvent
serverConfigUpdate chan gumble.ServerConfigEvent
serverConfig gumble.ServerConfigEvent
tlsConfig tls.Config
*bridge.Config
}
func New(cfg *bridge.Config) bridge.Bridger {
b := &Bmumble{}
b.Config = cfg
b.Nick = b.GetString("Nick")
b.local = make(chan config.Message)
b.running = make(chan error)
b.connected = make(chan gumble.DisconnectEvent)
b.serverConfigUpdate = make(chan gumble.ServerConfigEvent)
return b
}
func (b *Bmumble) Connect() error {
b.Log.Infof("Connecting %s", b.GetString("Server"))
host, portstr, err := net.SplitHostPort(b.GetString("Server"))
if err != nil {
return err
}
b.Host = host
_, err = strconv.Atoi(portstr)
if err != nil {
return err
}
if err = b.buildTLSConfig(); err != nil {
return err
}
go b.doSend()
go b.connectLoop()
err = <-b.running
return err
}
func (b *Bmumble) Disconnect() error {
return b.client.Disconnect()
}
func (b *Bmumble) JoinChannel(channel config.ChannelInfo) error {
cid, err := strconv.ParseUint(channel.Name, 10, 32)
if err != nil {
return err
}
channelID := uint32(cid)
if b.Channel != nil && *b.Channel != channelID {
b.Log.Fatalf("Cannot join channel ID '%d', already joined to channel ID %d", channelID, *b.Channel)
return errors.New("the Mumble bridge can only join a single channel")
}
b.Channel = &channelID
return b.doJoin(b.client, channelID)
}
func (b *Bmumble) Send(msg config.Message) (string, error) {
// Only process text messages
b.Log.Debugf("=> Received local message %#v", msg)
if msg.Event != "" && msg.Event != config.EventUserAction {
return "", nil
}
attachments := b.extractFiles(&msg)
b.local <- msg
for _, a := range attachments {
b.local <- a
}
return "", nil
}
func (b *Bmumble) buildTLSConfig() error {
b.tlsConfig = tls.Config{}
// Load TLS client certificate keypair required for registered user authentication
if cpath := b.GetString("TLSClientCertificate"); cpath != "" {
if ckey := b.GetString("TLSClientKey"); ckey != "" {
cert, err := tls.LoadX509KeyPair(cpath, ckey)
if err != nil {
return err
}
b.tlsConfig.Certificates = []tls.Certificate{cert}
}
}
// Load TLS CA used for server verification. If not provided, the Go system trust anchor is used
if capath := b.GetString("TLSCACertificate"); capath != "" {
ca, err := ioutil.ReadFile(capath)
if err != nil {
return err
}
b.tlsConfig.RootCAs = x509.NewCertPool()
b.tlsConfig.RootCAs.AppendCertsFromPEM(ca)
}
b.tlsConfig.InsecureSkipVerify = b.GetBool("SkipTLSVerify")
return nil
}
func (b *Bmumble) connectLoop() {
firstConnect := true
for {
err := b.doConnect()
if firstConnect {
b.running <- err
}
if err != nil {
b.Log.Errorf("Connection to server failed: %#v", err)
if firstConnect {
break
} else {
b.Log.Info("Retrying in 10s")
time.Sleep(10 * time.Second)
continue
}
}
firstConnect = false
d := <-b.connected
switch d.Type {
case gumble.DisconnectError:
b.Log.Errorf("Lost connection to the server (%s), attempting reconnect", d.String)
continue
case gumble.DisconnectKicked:
b.Log.Errorf("Kicked from the server (%s), attempting reconnect", d.String)
continue
case gumble.DisconnectBanned:
b.Log.Errorf("Banned from the server (%s), not attempting reconnect", d.String)
close(b.connected)
close(b.running)
return
case gumble.DisconnectUser:
b.Log.Infof("Disconnect successful")
close(b.connected)
close(b.running)
return
}
}
}
func (b *Bmumble) doConnect() error {
// Create new gumble config and attach event handlers
gumbleConfig := gumble.NewConfig()
gumbleConfig.Attach(gumbleutil.Listener{
ServerConfig: b.handleServerConfig,
TextMessage: b.handleTextMessage,
Connect: b.handleConnect,
Disconnect: b.handleDisconnect,
UserChange: b.handleUserChange,
})
gumbleConfig.Username = b.GetString("Nick")
if password := b.GetString("Password"); password != "" {
gumbleConfig.Password = password
}
client, err := gumble.DialWithDialer(new(net.Dialer), b.GetString("Server"), gumbleConfig, &b.tlsConfig)
if err != nil {
return err
}
b.client = client
return nil
}
func (b *Bmumble) doJoin(client *gumble.Client, channelID uint32) error {
channel, ok := client.Channels[channelID]
if !ok {
return fmt.Errorf("no channel with ID %d", channelID)
}
client.Self.Move(channel)
return nil
}
func (b *Bmumble) doSend() {
// Message sending loop that makes sure server-side
// restrictions and client-side message traits don't conflict
// with each other.
for {
select {
case serverConfig := <-b.serverConfigUpdate:
b.Log.Debugf("Received server config update: AllowHTML=%#v, MaximumMessageLength=%#v", serverConfig.AllowHTML, serverConfig.MaximumMessageLength)
b.serverConfig = serverConfig
case msg := <-b.local:
b.processMessage(&msg)
}
}
}
func (b *Bmumble) processMessage(msg *config.Message) {
b.Log.Debugf("Processing message %s", msg.Text)
allowHTML := true
if b.serverConfig.AllowHTML != nil {
allowHTML = *b.serverConfig.AllowHTML
}
// If this is a specially generated image message, send it unmodified
if msg.Event == "mumble_image" {
if allowHTML {
b.client.Self.Channel.Send(msg.Username+msg.Text, false)
} else {
b.Log.Info("Can't send image, server does not allow HTML messages")
}
return
}
// Don't process empty messages
if len(msg.Text) == 0 {
return
}
// If HTML is allowed, convert markdown into HTML, otherwise strip markdown
if allowHTML {
msg.Text = helper.ParseMarkdown(msg.Text)
} else {
msg.Text = stripmd.Strip(msg.Text)
}
// If there is a maximum message length, split and truncate the lines
var msgLines []string
if maxLength := b.serverConfig.MaximumMessageLength; maxLength != nil {
msgLines = helper.GetSubLines(msg.Text, *maxLength-len(msg.Username), b.GetString("MessageClipped"))
} else {
msgLines = helper.GetSubLines(msg.Text, 0, b.GetString("MessageClipped"))
}
// Send the individual lines
for i := range msgLines {
// Remove unnecessary newline character, since either way we're sending it as individual lines
msgLines[i] = strings.TrimSuffix(msgLines[i], "\n")
b.client.Self.Channel.Send(msg.Username+msgLines[i], false)
}
}

View File

@ -1,295 +0,0 @@
package nctalk
import (
"context"
"crypto/tls"
"strconv"
"strings"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"gomod.garykim.dev/nc-talk/ocs"
"gomod.garykim.dev/nc-talk/room"
"gomod.garykim.dev/nc-talk/user"
)
type Btalk struct {
user *user.TalkUser
rooms []Broom
*bridge.Config
}
func New(cfg *bridge.Config) bridge.Bridger {
return &Btalk{Config: cfg}
}
type Broom struct {
room *room.TalkRoom
ctx context.Context
ctxCancel context.CancelFunc
}
func (b *Btalk) Connect() error {
b.Log.Info("Connecting")
tconfig := &user.TalkUserConfig{
TLSConfig: &tls.Config{
InsecureSkipVerify: b.GetBool("SkipTLSVerify"), //nolint:gosec
},
}
var err error
b.user, err = user.NewUser(b.GetString("Server"), b.GetString("Login"), b.GetString("Password"), tconfig)
if err != nil {
b.Log.Error("Config could not be used")
return err
}
_, err = b.user.Capabilities()
if err != nil {
b.Log.Error("Cannot Connect")
return err
}
b.Log.Info("Connected")
return nil
}
func (b *Btalk) Disconnect() error {
for _, r := range b.rooms {
r.ctxCancel()
}
return nil
}
func (b *Btalk) JoinChannel(channel config.ChannelInfo) error {
tr, err := room.NewTalkRoom(b.user, channel.Name)
if err != nil {
return err
}
newRoom := Broom{
room: tr,
}
newRoom.ctx, newRoom.ctxCancel = context.WithCancel(context.Background())
c, err := newRoom.room.ReceiveMessages(newRoom.ctx)
if err != nil {
return err
}
b.rooms = append(b.rooms, newRoom)
go func() {
for msg := range c {
msg := msg
if msg.Error != nil {
b.Log.Errorf("Fatal message poll error: %s\n", msg.Error)
return
}
// Ignore messages that are from the bot user
if msg.ActorID == b.user.User || msg.ActorType == "bridged" {
continue
}
// Handle deleting messages
if msg.MessageType == ocs.MessageSystem && msg.Parent != nil && msg.Parent.MessageType == ocs.MessageDelete {
b.handleDeletingMessage(&msg, &newRoom)
continue
}
// Handle sending messages
if msg.MessageType == ocs.MessageComment {
b.handleSendingMessage(&msg, &newRoom)
continue
}
}
}()
return nil
}
func (b *Btalk) Send(msg config.Message) (string, error) {
r := b.getRoom(msg.Channel)
if r == nil {
b.Log.Errorf("Could not find room for %v", msg.Channel)
return "", nil
}
// Standard Message Send
if msg.Event == "" {
// Handle sending files if they are included
err := b.handleSendingFile(&msg, r)
if err != nil {
b.Log.Errorf("Could not send files in message to room %v from %v: %v", msg.Channel, msg.Username, err)
return "", nil
}
sentMessage, err := b.sendText(r, &msg, msg.Text)
if err != nil {
b.Log.Errorf("Could not send message to room %v from %v: %v", msg.Channel, msg.Username, err)
return "", nil
}
return strconv.Itoa(sentMessage.ID), nil
}
// Message Deletion
if msg.Event == config.EventMsgDelete {
messageID, err := strconv.Atoi(msg.ID)
if err != nil {
return "", err
}
data, err := r.room.DeleteMessage(messageID)
if err != nil {
return "", err
}
return strconv.Itoa(data.ID), nil
}
// Message is not a type that is currently supported
return "", nil
}
func (b *Btalk) getRoom(token string) *Broom {
for _, r := range b.rooms {
if r.room.Token == token {
return &r
}
}
return nil
}
func (b *Btalk) sendText(r *Broom, msg *config.Message, text string) (*ocs.TalkRoomMessageData, error) {
messageToSend := &room.Message{Message: msg.Username + text}
if b.GetBool("SeparateDisplayName") {
messageToSend.Message = text
messageToSend.ActorDisplayName = msg.Username
}
return r.room.SendComplexMessage(messageToSend)
}
func (b *Btalk) handleFiles(mmsg *config.Message, message *ocs.TalkRoomMessageData) error {
for _, parameter := range message.MessageParameters {
if parameter.Type == ocs.ROSTypeFile {
// Get the file
file, err := b.user.DownloadFile(parameter.Path)
if err != nil {
return err
}
if mmsg.Extra == nil {
mmsg.Extra = make(map[string][]interface{})
}
mmsg.Extra["file"] = append(mmsg.Extra["file"], config.FileInfo{
Name: parameter.Name,
Data: file,
Size: int64(len(*file)),
Avatar: false,
})
}
}
return nil
}
func (b *Btalk) handleSendingFile(msg *config.Message, r *Broom) error {
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
if fi.URL == "" {
continue
}
message := ""
if fi.Comment != "" {
message += fi.Comment + " "
}
message += fi.URL
_, err := b.sendText(r, msg, message)
if err != nil {
return err
}
}
return nil
}
func (b *Btalk) handleSendingMessage(msg *ocs.TalkRoomMessageData, r *Broom) {
remoteMessage := config.Message{
Text: formatRichObjectString(msg.Message, msg.MessageParameters),
Channel: r.room.Token,
Username: DisplayName(msg, b.guestSuffix()),
UserID: msg.ActorID,
Account: b.Account,
}
// It is possible for the ID to not be set on older versions of Talk so we only set it if
// the ID is not blank
if msg.ID != 0 {
remoteMessage.ID = strconv.Itoa(msg.ID)
}
// Handle Files
err := b.handleFiles(&remoteMessage, msg)
if err != nil {
b.Log.Errorf("Error handling file: %#v", msg)
return
}
b.Log.Debugf("<= Message is %#v", remoteMessage)
b.Remote <- remoteMessage
}
func (b *Btalk) handleDeletingMessage(msg *ocs.TalkRoomMessageData, r *Broom) {
remoteMessage := config.Message{
Event: config.EventMsgDelete,
Text: config.EventMsgDelete,
Channel: r.room.Token,
ID: strconv.Itoa(msg.Parent.ID),
Account: b.Account,
}
b.Log.Debugf("<= Message being deleted is %#v", remoteMessage)
b.Remote <- remoteMessage
}
func (b *Btalk) guestSuffix() string {
guestSuffix := " (Guest)"
if b.IsKeySet("GuestSuffix") {
guestSuffix = b.GetString("GuestSuffix")
}
return guestSuffix
}
// Spec: https://github.com/nextcloud/server/issues/1706#issue-182308785
func formatRichObjectString(message string, parameters map[string]ocs.RichObjectString) string {
for id, parameter := range parameters {
text := parameter.Name
switch parameter.Type {
case ocs.ROSTypeUser, ocs.ROSTypeGroup:
text = "@" + text
case ocs.ROSTypeFile:
if parameter.Link != "" {
text = parameter.Name
}
}
message = strings.ReplaceAll(message, "{"+id+"}", text)
}
return message
}
func DisplayName(msg *ocs.TalkRoomMessageData, suffix string) string {
if msg.ActorType == ocs.ActorGuest {
if msg.ActorDisplayName == "" {
return "Guest"
}
return msg.ActorDisplayName + suffix
}
return msg.ActorDisplayName
}

View File

@ -1,136 +0,0 @@
package brocketchat
import (
"fmt"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/matterbridge/Rocket.Chat.Go.SDK/models"
)
func (b *Brocketchat) handleRocket() {
messages := make(chan *config.Message)
if b.GetString("WebhookBindAddress") != "" {
b.Log.Debugf("Choosing webhooks based receiving")
go b.handleRocketHook(messages)
} else {
b.Log.Debugf("Choosing login/password based receiving")
go b.handleRocketClient(messages)
}
for message := range messages {
message.Account = b.Account
b.Log.Debugf("<= Sending message from %s on %s to gateway", message.Username, b.Account)
b.Log.Debugf("<= Message is %#v", message)
b.Remote <- *message
}
}
func (b *Brocketchat) handleRocketHook(messages chan *config.Message) {
for {
message := b.rh.Receive()
b.Log.Debugf("Receiving from rockethook %#v", message)
// do not loop
if message.UserName == b.GetString("Nick") {
continue
}
messages <- &config.Message{
UserID: message.UserID,
Username: message.UserName,
Text: message.Text,
Channel: message.ChannelName,
}
}
}
func (b *Brocketchat) handleStatusEvent(ev models.Message, rmsg *config.Message) bool {
switch ev.Type {
case "":
// this is a normal message, no processing needed
// return true so the message is not dropped
return true
case sUserJoined, sUserLeft:
rmsg.Event = config.EventJoinLeave
return true
case sRoomChangedTopic:
rmsg.Event = config.EventTopicChange
return true
}
b.Log.Debugf("Dropping message with unknown type: %s", ev.Type)
return false
}
func (b *Brocketchat) handleRocketClient(messages chan *config.Message) {
for message := range b.messageChan {
message := message
// skip messages with same ID, apparently messages get duplicated for an unknown reason
if _, ok := b.cache.Get(message.ID); ok {
continue
}
b.cache.Add(message.ID, true)
b.Log.Debugf("message %#v", message)
m := message
if b.skipMessage(&m) {
b.Log.Debugf("Skipped message: %#v", message)
continue
}
rmsg := &config.Message{Text: message.Msg,
Username: message.User.UserName,
Channel: b.getChannelName(message.RoomID),
Account: b.Account,
UserID: message.User.ID,
ID: message.ID,
Extra: make(map[string][]interface{}),
}
b.handleAttachments(&message, rmsg)
// handleStatusEvent returns false if the message should be dropped
// in that case it is probably some modification to the channel we do not want to relay
if b.handleStatusEvent(m, rmsg) {
messages <- rmsg
}
}
}
func (b *Brocketchat) handleAttachments(message *models.Message, rmsg *config.Message) {
if rmsg.Text == "" {
for _, attachment := range message.Attachments {
if attachment.Title != "" {
rmsg.Text = attachment.Title + "\n"
}
if attachment.Title != "" && attachment.Text != "" {
rmsg.Text += "\n"
}
if attachment.Text != "" {
rmsg.Text += attachment.Text
}
}
}
for i := range message.Attachments {
if err := b.handleDownloadFile(rmsg, &message.Attachments[i]); err != nil {
b.Log.Errorf("Could not download incoming file: %#v", err)
}
}
}
func (b *Brocketchat) handleDownloadFile(rmsg *config.Message, file *models.Attachment) error {
downloadURL := b.GetString("server") + file.TitleLink
data, err := helper.DownloadFileAuthRocket(downloadURL, b.user.Token, b.user.ID)
if err != nil {
return fmt.Errorf("download %s failed %#v", downloadURL, err)
}
helper.HandleDownloadData(b.Log, rmsg, file.Title, rmsg.Text, downloadURL, data, b.General)
return nil
}
func (b *Brocketchat) handleUploadFile(msg *config.Message) error {
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
if err := b.uploadFile(&fi, b.getChannelID(msg.Channel)); err != nil {
return err
}
}
return nil
}

View File

@ -1,201 +0,0 @@
package brocketchat
import (
"context"
"io/ioutil"
"mime"
"net/http"
"net/url"
"strings"
"time"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/42wim/matterbridge/hook/rockethook"
"github.com/42wim/matterbridge/matterhook"
"github.com/matterbridge/Rocket.Chat.Go.SDK/models"
"github.com/matterbridge/Rocket.Chat.Go.SDK/realtime"
"github.com/matterbridge/Rocket.Chat.Go.SDK/rest"
"github.com/nelsonken/gomf"
)
func (b *Brocketchat) doConnectWebhookBind() error {
switch {
case b.GetString("WebhookURL") != "":
b.Log.Info("Connecting using webhookurl (sending) and webhookbindaddress (receiving)")
b.mh = matterhook.New(b.GetString("WebhookURL"),
matterhook.Config{InsecureSkipVerify: b.GetBool("SkipTLSVerify"),
DisableServer: true})
b.rh = rockethook.New(b.GetString("WebhookURL"), rockethook.Config{BindAddress: b.GetString("WebhookBindAddress")})
case b.GetString("Login") != "":
b.Log.Info("Connecting using login/password (sending)")
err := b.apiLogin()
if err != nil {
return err
}
default:
b.Log.Info("Connecting using webhookbindaddress (receiving)")
b.rh = rockethook.New(b.GetString("WebhookURL"), rockethook.Config{BindAddress: b.GetString("WebhookBindAddress")})
}
return nil
}
func (b *Brocketchat) doConnectWebhookURL() error {
b.Log.Info("Connecting using webhookurl (sending)")
b.mh = matterhook.New(b.GetString("WebhookURL"),
matterhook.Config{InsecureSkipVerify: b.GetBool("SkipTLSVerify"),
DisableServer: true})
if b.GetString("Login") != "" {
b.Log.Info("Connecting using login/password (receiving)")
err := b.apiLogin()
if err != nil {
return err
}
}
return nil
}
func (b *Brocketchat) apiLogin() error {
b.Log.Debugf("handling apiLogin()")
credentials := &models.UserCredentials{Email: b.GetString("login"), Password: b.GetString("password")}
if b.GetString("Token") != "" {
credentials = &models.UserCredentials{ID: b.GetString("Login"), Token: b.GetString("Token")}
}
myURL, err := url.Parse(b.GetString("server"))
if err != nil {
return err
}
client, err := realtime.NewClient(myURL, b.GetBool("debug"))
b.c = client
if err != nil {
return err
}
restclient := rest.NewClient(myURL, b.GetBool("debug"))
user, err := b.c.Login(credentials)
if err != nil {
return err
}
b.user = user
b.r = restclient
err = b.r.Login(credentials)
if err != nil {
return err
}
b.Log.Info("Connection succeeded")
return nil
}
func (b *Brocketchat) getChannelName(id string) string {
b.RLock()
defer b.RUnlock()
if name, ok := b.channelMap[id]; ok {
return name
}
return ""
}
func (b *Brocketchat) getChannelID(name string) string {
b.RLock()
defer b.RUnlock()
for k, v := range b.channelMap {
if v == name || v == "#"+name {
return k
}
}
return ""
}
func (b *Brocketchat) skipMessage(message *models.Message) bool {
return message.User.ID == b.user.ID
}
func (b *Brocketchat) uploadFile(fi *config.FileInfo, channel string) error {
fb := gomf.New()
if err := fb.WriteField("description", fi.Comment); err != nil {
return err
}
sp := strings.Split(fi.Name, ".")
mtype := mime.TypeByExtension("." + sp[len(sp)-1])
if !strings.Contains(mtype, "image") && !strings.Contains(mtype, "video") {
return nil
}
if err := fb.WriteFile("file", fi.Name, mtype, *fi.Data); err != nil {
return err
}
req, err := fb.GetHTTPRequest(context.TODO(), b.GetString("server")+"/api/v1/rooms.upload/"+channel)
if err != nil {
return err
}
req.Header.Add("X-Auth-Token", b.user.Token)
req.Header.Add("X-User-Id", b.user.ID)
client := &http.Client{
Timeout: time.Second * 5,
}
resp, err := client.Do(req)
if err != nil {
return err
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != 200 {
b.Log.Errorf("failed: %#v", string(body))
}
return nil
}
// sendWebhook uses the configured WebhookURL to send the message
func (b *Brocketchat) sendWebhook(msg *config.Message) error {
// skip events
if msg.Event != "" {
return nil
}
if b.GetBool("PrefixMessagesWithNick") {
msg.Text = msg.Username + msg.Text
}
if msg.Extra != nil {
// this sends a message only if we received a config.EVENT_FILE_FAILURE_SIZE
for _, rmsg := range helper.HandleExtra(msg, b.General) {
rmsg := rmsg // scopelint
iconURL := config.GetIconURL(&rmsg, b.GetString("iconurl"))
matterMessage := matterhook.OMessage{
IconURL: iconURL,
Channel: rmsg.Channel,
UserName: rmsg.Username,
Text: rmsg.Text,
Props: make(map[string]interface{}),
}
if err := b.mh.Send(matterMessage); err != nil {
b.Log.Errorf("sendWebhook failed: %s ", err)
}
}
// webhook doesn't support file uploads, so we add the url manually
if len(msg.Extra["file"]) > 0 {
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
if fi.URL != "" {
msg.Text += fi.URL
}
}
}
}
iconURL := config.GetIconURL(msg, b.GetString("iconurl"))
matterMessage := matterhook.OMessage{
IconURL: iconURL,
Channel: msg.Channel,
UserName: msg.Username,
Text: msg.Text,
}
if msg.Avatar != "" {
matterMessage.IconURL = msg.Avatar
}
err := b.mh.Send(matterMessage)
if err != nil {
b.Log.Info(err)
return err
}
return nil
}

View File

@ -1,181 +0,0 @@
package brocketchat
import (
"errors"
"strings"
"sync"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/42wim/matterbridge/hook/rockethook"
"github.com/42wim/matterbridge/matterhook"
lru "github.com/hashicorp/golang-lru"
"github.com/matterbridge/Rocket.Chat.Go.SDK/models"
"github.com/matterbridge/Rocket.Chat.Go.SDK/realtime"
"github.com/matterbridge/Rocket.Chat.Go.SDK/rest"
)
type Brocketchat struct {
mh *matterhook.Client
rh *rockethook.Client
c *realtime.Client
r *rest.Client
cache *lru.Cache
*bridge.Config
messageChan chan models.Message
channelMap map[string]string
user *models.User
sync.RWMutex
}
const (
sUserJoined = "uj"
sUserLeft = "ul"
sRoomChangedTopic = "room_changed_topic"
)
func New(cfg *bridge.Config) bridge.Bridger {
newCache, err := lru.New(100)
if err != nil {
cfg.Log.Fatalf("Could not create LRU cache for rocketchat bridge: %v", err)
}
b := &Brocketchat{
Config: cfg,
messageChan: make(chan models.Message),
channelMap: make(map[string]string),
cache: newCache,
}
b.Log.Debugf("enabling rocketchat")
return b
}
func (b *Brocketchat) Command(cmd string) string {
return ""
}
func (b *Brocketchat) Connect() error {
if b.GetString("WebhookBindAddress") != "" {
if err := b.doConnectWebhookBind(); err != nil {
return err
}
go b.handleRocket()
return nil
}
switch {
case b.GetString("WebhookURL") != "":
if err := b.doConnectWebhookURL(); err != nil {
return err
}
go b.handleRocket()
return nil
case b.GetString("Login") != "":
b.Log.Info("Connecting using login/password (sending and receiving)")
err := b.apiLogin()
if err != nil {
return err
}
go b.handleRocket()
}
if b.GetString("WebhookBindAddress") == "" && b.GetString("WebhookURL") == "" &&
b.GetString("Login") == "" {
return errors.New("no connection method found. See that you have WebhookBindAddress, WebhookURL or Login/Password/Server configured")
}
return nil
}
func (b *Brocketchat) Disconnect() error {
return nil
}
func (b *Brocketchat) JoinChannel(channel config.ChannelInfo) error {
if b.c == nil {
return nil
}
id, err := b.c.GetChannelId(strings.TrimPrefix(channel.Name, "#"))
if err != nil {
return err
}
b.Lock()
b.channelMap[id] = channel.Name
b.Unlock()
mychannel := &models.Channel{ID: id, Name: strings.TrimPrefix(channel.Name, "#")}
if err := b.c.JoinChannel(id); err != nil {
return err
}
if err := b.c.SubscribeToMessageStream(mychannel, b.messageChan); err != nil {
return err
}
return nil
}
func (b *Brocketchat) Send(msg config.Message) (string, error) {
// strip the # if people has set this
msg.Channel = strings.TrimPrefix(msg.Channel, "#")
channel := &models.Channel{ID: b.getChannelID(msg.Channel), Name: msg.Channel}
// Make a action /me of the message
if msg.Event == config.EventUserAction {
msg.Text = "_" + msg.Text + "_"
}
// Delete message
if msg.Event == config.EventMsgDelete {
if msg.ID == "" {
return "", nil
}
return msg.ID, b.c.DeleteMessage(&models.Message{ID: msg.ID})
}
// Use webhook to send the message
if b.GetString("WebhookURL") != "" {
return "", b.sendWebhook(&msg)
}
// Prepend nick if configured
if b.GetBool("PrefixMessagesWithNick") {
msg.Text = msg.Username + msg.Text
}
// Edit message if we have an ID
if msg.ID != "" {
return msg.ID, b.c.EditMessage(&models.Message{ID: msg.ID, Msg: msg.Text, RoomID: b.getChannelID(msg.Channel)})
}
// Upload a file if it exists
if msg.Extra != nil {
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
// strip the # if people has set this
rmsg.Channel = strings.TrimPrefix(rmsg.Channel, "#")
smsg := &models.Message{
RoomID: b.getChannelID(rmsg.Channel),
Msg: rmsg.Username + rmsg.Text,
PostMessage: models.PostMessage{
Avatar: rmsg.Avatar,
Alias: rmsg.Username,
},
}
if _, err := b.c.SendMessage(smsg); err != nil {
b.Log.Errorf("SendMessage failed: %s", err)
}
}
if len(msg.Extra["file"]) > 0 {
return "", b.handleUploadFile(&msg)
}
}
smsg := &models.Message{
RoomID: channel.ID,
Msg: msg.Text,
PostMessage: models.PostMessage{
Avatar: msg.Avatar,
Alias: msg.Username,
},
}
rmsg, err := b.c.SendMessage(smsg)
if rmsg == nil {
return "", err
}
return rmsg.ID, err
}

View File

@ -1,375 +0,0 @@
package bslack
import (
"errors"
"fmt"
"html"
"time"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/slack-go/slack"
)
// ErrEventIgnored is for events that should be ignored
var ErrEventIgnored = errors.New("this event message should ignored")
func (b *Bslack) handleSlack() {
messages := make(chan *config.Message)
if b.GetString(incomingWebhookConfig) != "" && b.GetString(tokenConfig) == "" {
b.Log.Debugf("Choosing webhooks based receiving")
go b.handleMatterHook(messages)
} else {
b.Log.Debugf("Choosing token based receiving")
go b.handleSlackClient(messages)
}
time.Sleep(time.Second)
b.Log.Debug("Start listening for Slack messages")
for message := range messages {
// don't do any action on deleted/typing messages
if message.Event != config.EventUserTyping && message.Event != config.EventMsgDelete {
b.Log.Debugf("<= Sending message from %s on %s to gateway", message.Username, b.Account)
// cleanup the message
message.Text = b.replaceMention(message.Text)
message.Text = b.replaceVariable(message.Text)
message.Text = b.replaceChannel(message.Text)
message.Text = b.replaceURL(message.Text)
message.Text = b.replaceb0rkedMarkDown(message.Text)
message.Text = html.UnescapeString(message.Text)
// Add the avatar
message.Avatar = b.users.getAvatar(message.UserID)
}
b.Log.Debugf("<= Message is %#v", message)
b.Remote <- *message
}
}
func (b *Bslack) handleSlackClient(messages chan *config.Message) {
for msg := range b.rtm.IncomingEvents {
if msg.Type != sUserTyping && msg.Type != sHello && msg.Type != sLatencyReport {
b.Log.Debugf("== Receiving event %#v", msg.Data)
}
switch ev := msg.Data.(type) {
case *slack.UserTypingEvent:
if !b.GetBool("ShowUserTyping") {
continue
}
rmsg, err := b.handleTypingEvent(ev)
if err == ErrEventIgnored {
continue
} else if err != nil {
b.Log.Errorf("%#v", err)
continue
}
messages <- rmsg
case *slack.MessageEvent:
if b.skipMessageEvent(ev) {
b.Log.Debugf("Skipped message: %#v", ev)
continue
}
rmsg, err := b.handleMessageEvent(ev)
if err != nil {
b.Log.Errorf("%#v", err)
continue
}
messages <- rmsg
case *slack.OutgoingErrorEvent:
b.Log.Debugf("%#v", ev.Error())
case *slack.ChannelJoinedEvent:
// When we join a channel we update the full list of users as
// well as the information for the channel that we joined as this
// should now tell that we are a member of it.
b.channels.registerChannel(ev.Channel)
case *slack.ConnectedEvent:
b.si = ev.Info
b.channels.populateChannels(true)
b.users.populateUsers(true)
case *slack.InvalidAuthEvent:
b.Log.Fatalf("Invalid Token %#v", ev)
case *slack.ConnectionErrorEvent:
b.Log.Errorf("Connection failed %#v %#v", ev.Error(), ev.ErrorObj)
case *slack.MemberJoinedChannelEvent:
b.users.populateUser(ev.User)
case *slack.HelloEvent, *slack.LatencyReport, *slack.ConnectingEvent:
continue
case *slack.UserChangeEvent:
b.users.invalidateUser(ev.User.ID)
default:
b.Log.Debugf("Unhandled incoming event: %T", ev)
}
}
}
func (b *Bslack) handleMatterHook(messages chan *config.Message) {
for {
message := b.mh.Receive()
b.Log.Debugf("receiving from matterhook (slack) %#v", message)
if message.UserName == "slackbot" {
continue
}
messages <- &config.Message{
Username: message.UserName,
Text: message.Text,
Channel: message.ChannelName,
}
}
}
// skipMessageEvent skips event that need to be skipped :-)
func (b *Bslack) skipMessageEvent(ev *slack.MessageEvent) bool {
switch ev.SubType {
case sChannelLeave, sChannelJoin:
return b.GetBool(noSendJoinConfig)
case sPinnedItem, sUnpinnedItem:
return true
case sChannelTopic, sChannelPurpose:
// Skip the event if our bot/user account changed the topic/purpose
if ev.User == b.si.User.ID {
return true
}
}
// Check for our callback ID
hasOurCallbackID := false
if len(ev.Blocks.BlockSet) == 1 {
block, ok := ev.Blocks.BlockSet[0].(*slack.SectionBlock)
hasOurCallbackID = ok && block.BlockID == "matterbridge_"+b.uuid
}
if ev.SubMessage != nil {
// It seems ev.SubMessage.Edited == nil when slack unfurls.
// Do not forward these messages. See Github issue #266.
if ev.SubMessage.ThreadTimestamp != ev.SubMessage.Timestamp &&
ev.SubMessage.Edited == nil {
return true
}
// see hidden subtypes at https://api.slack.com/events/message
// these messages are sent when we add a message to a thread #709
if ev.SubType == "message_replied" && ev.Hidden {
return true
}
if len(ev.SubMessage.Blocks.BlockSet) == 1 {
block, ok := ev.SubMessage.Blocks.BlockSet[0].(*slack.SectionBlock)
hasOurCallbackID = ok && block.BlockID == "matterbridge_"+b.uuid
}
}
// Skip any messages that we made ourselves or from 'slackbot' (see #527).
if ev.Username == sSlackBotUser ||
(b.rtm != nil && ev.Username == b.si.User.Name) || hasOurCallbackID {
return true
}
if len(ev.Files) > 0 {
return b.filesCached(ev.Files)
}
return false
}
func (b *Bslack) filesCached(files []slack.File) bool {
for i := range files {
if !b.fileCached(&files[i]) {
return false
}
}
return true
}
// handleMessageEvent handles the message events. Together with any called sub-methods,
// this method implements the following event processing pipeline:
//
// 1. Check if the message should be ignored.
// NOTE: This is not actually part of the method below but is done just before it
// is called via the 'skipMessageEvent()' method.
// 2. Populate the Matterbridge message that will be sent to the router based on the
// received event and logic that is common to all events that are not skipped.
// 3. Detect and handle any message that is "status" related (think join channel, etc.).
// This might result in an early exit from the pipeline and passing of the
// pre-populated message to the Matterbridge router.
// 4. Handle the specific case of messages that edit existing messages depending on
// configuration.
// 5. Handle any attachments of the received event.
// 6. Check that the Matterbridge message that we end up with after at the end of the
// pipeline is valid before sending it to the Matterbridge router.
func (b *Bslack) handleMessageEvent(ev *slack.MessageEvent) (*config.Message, error) {
rmsg, err := b.populateReceivedMessage(ev)
if err != nil {
return nil, err
}
// Handle some message types early.
if b.handleStatusEvent(ev, rmsg) {
return rmsg, nil
}
b.handleAttachments(ev, rmsg)
// Verify that we have the right information and the message
// is well-formed before sending it out to the router.
if len(ev.Files) == 0 && (rmsg.Text == "" || rmsg.Username == "") {
if ev.BotID != "" {
// This is probably a webhook we couldn't resolve.
return nil, fmt.Errorf("message handling resulted in an empty bot message (probably an incoming webhook we couldn't resolve): %#v", ev)
}
if ev.SubMessage != nil {
return nil, fmt.Errorf("message handling resulted in an empty message: %#v with submessage %#v", ev, ev.SubMessage)
}
return nil, fmt.Errorf("message handling resulted in an empty message: %#v", ev)
}
return rmsg, nil
}
func (b *Bslack) handleStatusEvent(ev *slack.MessageEvent, rmsg *config.Message) bool {
switch ev.SubType {
case sChannelJoined, sMemberJoined:
// There's no further processing needed on channel events
// so we return 'true'.
return true
case sChannelJoin, sChannelLeave:
rmsg.Username = sSystemUser
rmsg.Event = config.EventJoinLeave
case sChannelTopic, sChannelPurpose:
b.channels.populateChannels(false)
rmsg.Event = config.EventTopicChange
case sMessageChanged:
rmsg.Text = ev.SubMessage.Text
// handle deleted thread starting messages
if ev.SubMessage.Text == "This message was deleted." {
rmsg.Event = config.EventMsgDelete
return true
}
case sMessageDeleted:
rmsg.Text = config.EventMsgDelete
rmsg.Event = config.EventMsgDelete
rmsg.ID = ev.DeletedTimestamp
// If a message is being deleted we do not need to process
// the event any further so we return 'true'.
return true
case sMeMessage:
rmsg.Event = config.EventUserAction
}
return false
}
func (b *Bslack) handleAttachments(ev *slack.MessageEvent, rmsg *config.Message) {
// File comments are set by the system (because there is no username given).
if ev.SubType == sFileComment {
rmsg.Username = sSystemUser
}
// See if we have some text in the attachments.
if rmsg.Text == "" {
for _, attach := range ev.Attachments {
if attach.Text != "" {
if attach.Title != "" {
rmsg.Text = attach.Title + "\n"
}
rmsg.Text += attach.Text
} else {
rmsg.Text = attach.Fallback
}
}
}
// Save the attachments, so that we can send them to other slack (compatible) bridges.
if len(ev.Attachments) > 0 {
rmsg.Extra[sSlackAttachment] = append(rmsg.Extra[sSlackAttachment], ev.Attachments)
}
// If we have files attached, download them (in memory) and put a pointer to it in msg.Extra.
for i := range ev.Files {
if err := b.handleDownloadFile(rmsg, &ev.Files[i], false); err != nil {
b.Log.Errorf("Could not download incoming file: %#v", err)
}
}
}
func (b *Bslack) handleTypingEvent(ev *slack.UserTypingEvent) (*config.Message, error) {
if ev.User == b.si.User.ID {
return nil, ErrEventIgnored
}
channelInfo, err := b.channels.getChannelByID(ev.Channel)
if err != nil {
return nil, err
}
return &config.Message{
Channel: channelInfo.Name,
Account: b.Account,
Event: config.EventUserTyping,
}, nil
}
// handleDownloadFile handles file download
func (b *Bslack) handleDownloadFile(rmsg *config.Message, file *slack.File, retry bool) error {
if b.fileCached(file) {
return nil
}
// Check that the file is neither too large nor blacklisted.
if err := helper.HandleDownloadSize(b.Log, rmsg, file.Name, int64(file.Size), b.General); err != nil {
b.Log.WithError(err).Infof("Skipping download of incoming file.")
return nil
}
// Actually download the file.
data, err := helper.DownloadFileAuth(file.URLPrivateDownload, "Bearer "+b.GetString(tokenConfig))
if err != nil {
return fmt.Errorf("download %s failed %#v", file.URLPrivateDownload, err)
}
if len(*data) != file.Size && !retry {
b.Log.Debugf("Data size (%d) is not equal to size declared (%d)\n", len(*data), file.Size)
time.Sleep(1 * time.Second)
return b.handleDownloadFile(rmsg, file, true)
}
// If a comment is attached to the file(s) it is in the 'Text' field of the Slack messge event
// and should be added as comment to only one of the files. We reset the 'Text' field to ensure
// that the comment is not duplicated.
comment := rmsg.Text
rmsg.Text = ""
helper.HandleDownloadData(b.Log, rmsg, file.Name, comment, file.URLPrivateDownload, data, b.General)
return nil
}
// handleGetChannelMembers handles messages containing the GetChannelMembers event
// Sends a message to the router containing *config.ChannelMembers
func (b *Bslack) handleGetChannelMembers(rmsg *config.Message) bool {
if rmsg.Event != config.EventGetChannelMembers {
return false
}
cMembers := b.channels.getChannelMembers(b.users)
extra := make(map[string][]interface{})
extra[config.EventGetChannelMembers] = append(extra[config.EventGetChannelMembers], cMembers)
msg := config.Message{
Extra: extra,
Event: config.EventGetChannelMembers,
Account: b.Account,
}
b.Log.Debugf("sending msg to remote %#v", msg)
b.Remote <- msg
return true
}
// fileCached implements Matterbridge's caching logic for files
// shared via Slack.
//
// We consider that a file was cached if its ID was added in the last minute or
// it's name was registered in the last 10 seconds. This ensures that an
// identically named file but with different content will be uploaded correctly
// (the assumption is that such name collisions will not occur within the given
// timeframes).
func (b *Bslack) fileCached(file *slack.File) bool {
if ts, ok := b.cache.Get("file" + file.ID); ok && time.Since(ts.(time.Time)) < time.Minute {
return true
} else if ts, ok = b.cache.Get("filename" + file.Name); ok && time.Since(ts.(time.Time)) < 10*time.Second {
return true
}
return false
}

View File

@ -1,259 +0,0 @@
package bslack
import (
"fmt"
"regexp"
"strings"
"time"
"github.com/42wim/matterbridge/bridge/config"
"github.com/sirupsen/logrus"
"github.com/slack-go/slack"
)
// populateReceivedMessage shapes the initial Matterbridge message that we will forward to the
// router before we apply message-dependent modifications.
func (b *Bslack) populateReceivedMessage(ev *slack.MessageEvent) (*config.Message, error) {
// Use our own func because rtm.GetChannelInfo doesn't work for private channels.
channel, err := b.channels.getChannelByID(ev.Channel)
if err != nil {
return nil, err
}
rmsg := &config.Message{
Text: ev.Text,
Channel: channel.Name,
Account: b.Account,
ID: ev.Timestamp,
Extra: make(map[string][]interface{}),
ParentID: ev.ThreadTimestamp,
Protocol: b.Protocol,
}
if b.useChannelID {
rmsg.Channel = "ID:" + channel.ID
}
// Handle 'edit' messages.
if ev.SubMessage != nil && !b.GetBool(editDisableConfig) {
rmsg.ID = ev.SubMessage.Timestamp
if ev.SubMessage.ThreadTimestamp != ev.SubMessage.Timestamp {
b.Log.Debugf("SubMessage %#v", ev.SubMessage)
rmsg.Text = ev.SubMessage.Text + b.GetString(editSuffixConfig)
}
}
// For edits, only submessage has thread ts.
// Ensures edits to threaded messages maintain their prefix hint on the
// unthreaded end.
if ev.SubMessage != nil {
rmsg.ParentID = ev.SubMessage.ThreadTimestamp
}
if err = b.populateMessageWithUserInfo(ev, rmsg); err != nil {
return nil, err
}
return rmsg, err
}
func (b *Bslack) populateMessageWithUserInfo(ev *slack.MessageEvent, rmsg *config.Message) error {
if ev.SubType == sMessageDeleted || ev.SubType == sFileComment {
return nil
}
// First, deal with bot-originating messages but only do so when not using webhooks: we
// would not be able to distinguish which bot would be sending them.
if err := b.populateMessageWithBotInfo(ev, rmsg); err != nil {
return err
}
// Second, deal with "real" users if we have the necessary information.
var userID string
switch {
case ev.User != "":
userID = ev.User
case ev.SubMessage != nil && ev.SubMessage.User != "":
userID = ev.SubMessage.User
default:
return nil
}
user := b.users.getUser(userID)
if user == nil {
return fmt.Errorf("could not find information for user with id %s", ev.User)
}
rmsg.UserID = user.ID
rmsg.Username = user.Name
if user.Profile.DisplayName != "" {
rmsg.Username = user.Profile.DisplayName
}
return nil
}
func (b *Bslack) populateMessageWithBotInfo(ev *slack.MessageEvent, rmsg *config.Message) error {
if ev.BotID == "" || b.GetString(outgoingWebhookConfig) != "" {
return nil
}
var err error
var bot *slack.Bot
for {
bot, err = b.rtm.GetBotInfo(ev.BotID)
if err == nil {
break
}
if err = handleRateLimit(b.Log, err); err != nil {
b.Log.Errorf("Could not retrieve bot information: %#v", err)
return err
}
}
b.Log.Debugf("Found bot %#v", bot)
if bot.Name != "" {
rmsg.Username = bot.Name
if ev.Username != "" {
rmsg.Username = ev.Username
}
rmsg.UserID = bot.ID
}
return nil
}
var (
mentionRE = regexp.MustCompile(`<@([a-zA-Z0-9]+)>`)
channelRE = regexp.MustCompile(`<#[a-zA-Z0-9]+\|(.+?)>`)
variableRE = regexp.MustCompile(`<!((?:subteam\^)?[a-zA-Z0-9]+)(?:\|@?(.+?))?>`)
urlRE = regexp.MustCompile(`<(.*?)(\|.*?)?>`)
codeFenceRE = regexp.MustCompile(`(?m)^` + "```" + `\w+$`)
topicOrPurposeRE = regexp.MustCompile(`(?s)(@.+) (cleared|set)(?: the)? channel (topic|purpose)(?:: (.*))?`)
)
func (b *Bslack) extractTopicOrPurpose(text string) (string, string) {
r := topicOrPurposeRE.FindStringSubmatch(text)
if len(r) == 5 {
action, updateType, extracted := r[2], r[3], r[4]
switch action {
case "set":
return updateType, extracted
case "cleared":
return updateType, ""
}
}
b.Log.Warnf("Encountered channel topic or purpose change message with unexpected format: %s", text)
return "unknown", ""
}
// @see https://api.slack.com/docs/message-formatting#linking_to_channels_and_users
func (b *Bslack) replaceMention(text string) string {
replaceFunc := func(match string) string {
userID := strings.Trim(match, "@<>")
if username := b.users.getUsername(userID); userID != "" {
return "@" + username
}
return match
}
return mentionRE.ReplaceAllStringFunc(text, replaceFunc)
}
// @see https://api.slack.com/docs/message-formatting#linking_to_channels_and_users
func (b *Bslack) replaceChannel(text string) string {
for _, r := range channelRE.FindAllStringSubmatch(text, -1) {
text = strings.Replace(text, r[0], "#"+r[1], 1)
}
return text
}
// @see https://api.slack.com/docs/message-formatting#variables
func (b *Bslack) replaceVariable(text string) string {
for _, r := range variableRE.FindAllStringSubmatch(text, -1) {
if r[2] != "" {
text = strings.Replace(text, r[0], "@"+r[2], 1)
} else {
text = strings.Replace(text, r[0], "@"+r[1], 1)
}
}
return text
}
// @see https://api.slack.com/docs/message-formatting#linking_to_urls
func (b *Bslack) replaceURL(text string) string {
for _, r := range urlRE.FindAllStringSubmatch(text, -1) {
if len(strings.TrimSpace(r[2])) == 1 { // A display text separator was found, but the text was blank
text = strings.Replace(text, r[0], "", 1)
} else {
text = strings.Replace(text, r[0], r[1], 1)
}
}
return text
}
func (b *Bslack) replaceb0rkedMarkDown(text string) string {
// taken from https://github.com/mattermost/mattermost-server/blob/master/app/slackimport.go
//
regexReplaceAllString := []struct {
regex *regexp.Regexp
rpl string
}{
// bold
{
regexp.MustCompile(`(^|[\s.;,])\*(\S[^*\n]+)\*`),
"$1**$2**",
},
// strikethrough
{
regexp.MustCompile(`(^|[\s.;,])\~(\S[^~\n]+)\~`),
"$1~~$2~~",
},
// single paragraph blockquote
// Slack converts > character to &gt;
{
regexp.MustCompile(`(?sm)^&gt;`),
">",
},
}
for _, rule := range regexReplaceAllString {
text = rule.regex.ReplaceAllString(text, rule.rpl)
}
return text
}
func (b *Bslack) replaceCodeFence(text string) string {
return codeFenceRE.ReplaceAllString(text, "```")
}
// getUsersInConversation returns an array of userIDs that are members of channelID
func (b *Bslack) getUsersInConversation(channelID string) ([]string, error) {
channelMembers := []string{}
for {
queryParams := &slack.GetUsersInConversationParameters{
ChannelID: channelID,
}
members, nextCursor, err := b.sc.GetUsersInConversation(queryParams)
if err != nil {
if err = handleRateLimit(b.Log, err); err != nil {
return channelMembers, fmt.Errorf("Could not retrieve users in channels: %#v", err)
}
continue
}
channelMembers = append(channelMembers, members...)
if nextCursor == "" {
break
}
queryParams.Cursor = nextCursor
}
return channelMembers, nil
}
func handleRateLimit(log *logrus.Entry, err error) error {
rateLimit, ok := err.(*slack.RateLimitedError)
if !ok {
return err
}
log.Infof("Rate-limited by Slack. Sleeping for %v", rateLimit.RetryAfter)
time.Sleep(rateLimit.RetryAfter)
return nil
}

View File

@ -1,36 +0,0 @@
package bslack
import (
"io/ioutil"
"testing"
"github.com/42wim/matterbridge/bridge"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
func TestExtractTopicOrPurpose(t *testing.T) {
testcases := map[string]struct {
input string
wantChangeType string
wantOutput string
}{
"success - topic type": {"@someone set channel topic: foo bar", "topic", "foo bar"},
"success - purpose type": {"@someone set channel purpose: foo bar", "purpose", "foo bar"},
"success - one line": {"@someone set channel topic: foo bar", "topic", "foo bar"},
"success - multi-line": {"@someone set channel topic: foo\nbar", "topic", "foo\nbar"},
"success - cleared": {"@someone cleared channel topic", "topic", ""},
"error - unhandled": {"some unmatched message", "unknown", ""},
}
logger := logrus.New()
logger.SetOutput(ioutil.Discard)
cfg := &bridge.Config{Bridge: &bridge.Bridge{Log: logrus.NewEntry(logger)}}
b := newBridge(cfg)
for name, tc := range testcases {
gotChangeType, gotOutput := b.extractTopicOrPurpose(tc.input)
assert.Equalf(t, tc.wantChangeType, gotChangeType, "This testcase failed: %s", name)
assert.Equalf(t, tc.wantOutput, gotOutput, "This testcase failed: %s", name)
}
}

View File

@ -1,80 +0,0 @@
package bslack
import (
"errors"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/matterhook"
"github.com/slack-go/slack"
)
type BLegacy struct {
*Bslack
}
func NewLegacy(cfg *bridge.Config) bridge.Bridger {
b := &BLegacy{Bslack: newBridge(cfg)}
b.legacy = true
return b
}
func (b *BLegacy) Connect() error {
b.RLock()
defer b.RUnlock()
if b.GetString(incomingWebhookConfig) != "" {
switch {
case b.GetString(outgoingWebhookConfig) != "":
b.Log.Info("Connecting using webhookurl (sending) and webhookbindaddress (receiving)")
b.mh = matterhook.New(b.GetString(outgoingWebhookConfig), matterhook.Config{
InsecureSkipVerify: b.GetBool(skipTLSConfig),
BindAddress: b.GetString(incomingWebhookConfig),
})
case b.GetString(tokenConfig) != "":
b.Log.Info("Connecting using token (sending)")
b.sc = slack.New(b.GetString(tokenConfig))
b.rtm = b.sc.NewRTM()
go b.rtm.ManageConnection()
b.Log.Info("Connecting using webhookbindaddress (receiving)")
b.mh = matterhook.New(b.GetString(outgoingWebhookConfig), matterhook.Config{
InsecureSkipVerify: b.GetBool(skipTLSConfig),
BindAddress: b.GetString(incomingWebhookConfig),
})
default:
b.Log.Info("Connecting using webhookbindaddress (receiving)")
b.mh = matterhook.New(b.GetString(outgoingWebhookConfig), matterhook.Config{
InsecureSkipVerify: b.GetBool(skipTLSConfig),
BindAddress: b.GetString(incomingWebhookConfig),
})
}
go b.handleSlack()
return nil
}
if b.GetString(outgoingWebhookConfig) != "" {
b.Log.Info("Connecting using webhookurl (sending)")
b.mh = matterhook.New(b.GetString(outgoingWebhookConfig), matterhook.Config{
InsecureSkipVerify: b.GetBool(skipTLSConfig),
DisableServer: true,
})
if b.GetString(tokenConfig) != "" {
b.Log.Info("Connecting using token (receiving)")
b.sc = slack.New(b.GetString(tokenConfig), slack.OptionDebug(b.GetBool("debug")))
b.channels = newChannelManager(b.Log, b.sc)
b.users = newUserManager(b.Log, b.sc)
b.rtm = b.sc.NewRTM()
go b.rtm.ManageConnection()
go b.handleSlack()
}
} else if b.GetString(tokenConfig) != "" {
b.Log.Info("Connecting using token (sending and receiving)")
b.sc = slack.New(b.GetString(tokenConfig), slack.OptionDebug(b.GetBool("debug")))
b.channels = newChannelManager(b.Log, b.sc)
b.users = newUserManager(b.Log, b.sc)
b.rtm = b.sc.NewRTM()
go b.rtm.ManageConnection()
go b.handleSlack()
}
if b.GetString(incomingWebhookConfig) == "" && b.GetString(outgoingWebhookConfig) == "" && b.GetString(tokenConfig) == "" {
return errors.New("no connection method found. See that you have WebhookBindAddress, WebhookURL or Token configured")
}
return nil
}

View File

@ -1,94 +1,49 @@
package bslack
import (
"bytes"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/42wim/matterbridge/matterhook"
lru "github.com/hashicorp/golang-lru"
"github.com/rs/xid"
"github.com/slack-go/slack"
log "github.com/Sirupsen/logrus"
"github.com/nlopes/slack"
"strings"
"time"
)
type MMMessage struct {
Text string
Channel string
Username string
Raw *slack.MessageEvent
}
type Bslack struct {
sync.RWMutex
*bridge.Config
mh *matterhook.Client
sc *slack.Client
rtm *slack.RTM
si *slack.Info
cache *lru.Cache
uuid string
useChannelID bool
channels *channels
users *users
legacy bool
mh *matterhook.Client
sc *slack.Client
Config *config.Protocol
rtm *slack.RTM
Plus bool
Remote chan config.Message
Users []slack.User
protocol string
origin string
si *slack.Info
channels []slack.Channel
}
const (
sHello = "hello"
sChannelJoin = "channel_join"
sChannelLeave = "channel_leave"
sChannelJoined = "channel_joined"
sMemberJoined = "member_joined_channel"
sMessageChanged = "message_changed"
sMessageDeleted = "message_deleted"
sSlackAttachment = "slack_attachment"
sPinnedItem = "pinned_item"
sUnpinnedItem = "unpinned_item"
sChannelTopic = "channel_topic"
sChannelPurpose = "channel_purpose"
sFileComment = "file_comment"
sMeMessage = "me_message"
sUserTyping = "user_typing"
sLatencyReport = "latency_report"
sSystemUser = "system"
sSlackBotUser = "slackbot"
var flog *log.Entry
var protocol = "slack"
tokenConfig = "Token"
incomingWebhookConfig = "WebhookBindAddress"
outgoingWebhookConfig = "WebhookURL"
skipTLSConfig = "SkipTLSVerify"
useNickPrefixConfig = "PrefixMessagesWithNick"
editDisableConfig = "EditDisable"
editSuffixConfig = "EditSuffix"
iconURLConfig = "iconurl"
noSendJoinConfig = "nosendjoinpart"
messageLength = 3000
)
func New(cfg *bridge.Config) bridge.Bridger {
// Print a deprecation warning for legacy non-bot tokens (#527).
token := cfg.GetString(tokenConfig)
if token != "" && !strings.HasPrefix(token, "xoxb") {
cfg.Log.Warn("Non-bot token detected. It is STRONGLY recommended to use a proper bot-token instead.")
cfg.Log.Warn("Legacy tokens may be deprecated by Slack at short notice. See the Matterbridge GitHub wiki for a migration guide.")
cfg.Log.Warn("See https://github.com/42wim/matterbridge/wiki/Slack-bot-setup")
return NewLegacy(cfg)
}
return newBridge(cfg)
func init() {
flog = log.WithFields(log.Fields{"module": protocol})
}
func newBridge(cfg *bridge.Config) *Bslack {
newCache, err := lru.New(5000)
if err != nil {
cfg.Log.Fatalf("Could not create LRU cache for Slack bridge: %v", err)
}
b := &Bslack{
Config: cfg,
uuid: xid.New().String(),
cache: newCache,
}
func New(cfg config.Protocol, origin string, c chan config.Message) *Bslack {
b := &Bslack{}
b.Config = &cfg
b.Remote = c
b.protocol = protocol
b.origin = origin
return b
}
@ -97,459 +52,187 @@ func (b *Bslack) Command(cmd string) string {
}
func (b *Bslack) Connect() error {
b.RLock()
defer b.RUnlock()
if b.GetString(incomingWebhookConfig) == "" && b.GetString(outgoingWebhookConfig) == "" && b.GetString(tokenConfig) == "" {
return errors.New("no connection method found: WebhookBindAddress, WebhookURL or Token need to be configured")
}
// If we have a token we use the Slack websocket-based RTM for both sending and receiving.
if token := b.GetString(tokenConfig); token != "" {
b.Log.Info("Connecting using token")
b.sc = slack.New(token, slack.OptionDebug(b.GetBool("Debug")))
b.channels = newChannelManager(b.Log, b.sc)
b.users = newUserManager(b.Log, b.sc)
flog.Info("Connecting")
if !b.Config.UseAPI {
b.mh = matterhook.New(b.Config.URL,
matterhook.Config{BindAddress: b.Config.BindAddress})
} else {
b.sc = slack.New(b.Config.Token)
b.rtm = b.sc.NewRTM()
go b.rtm.ManageConnection()
go b.handleSlack()
return nil
}
// In absence of a token we fall back to incoming and outgoing Webhooks.
b.mh = matterhook.New(
"",
matterhook.Config{
InsecureSkipVerify: b.GetBool("SkipTLSVerify"),
DisableServer: true,
},
)
if b.GetString(outgoingWebhookConfig) != "" {
b.Log.Info("Using specified webhook for outgoing messages.")
b.mh.Url = b.GetString(outgoingWebhookConfig)
}
if b.GetString(incomingWebhookConfig) != "" {
b.Log.Info("Setting up local webhook for incoming messages.")
b.mh.BindAddress = b.GetString(incomingWebhookConfig)
b.mh.DisableServer = false
go b.handleSlack()
}
flog.Info("Connection succeeded")
go b.handleSlack()
return nil
}
func (b *Bslack) Disconnect() error {
return b.rtm.Disconnect()
func (b *Bslack) FullOrigin() string {
return b.protocol + "." + b.origin
}
// JoinChannel only acts as a verification method that checks whether Matterbridge's
// Slack integration is already member of the channel. This is because Slack does not
// allow apps or bots to join channels themselves and they need to be invited
// manually by a user.
func (b *Bslack) JoinChannel(channel config.ChannelInfo) error {
// We can only join a channel through the Slack API.
if b.sc == nil {
return nil
}
// try to join a channel when in legacy
if b.legacy {
_, _, _, err := b.sc.JoinConversation(channel.Name)
func (b *Bslack) JoinChannel(channel string) error {
// we can only join channels using the API
if b.Config.UseAPI {
_, err := b.sc.JoinChannel(channel)
if err != nil {
switch err.Error() {
case "name_taken", "restricted_action":
case "default":
return err
}
}
}
b.channels.populateChannels(false)
channelInfo, err := b.channels.getChannel(channel.Name)
if err != nil {
return fmt.Errorf("could not join channel: %#v", err)
}
if strings.HasPrefix(channel.Name, "ID:") {
b.useChannelID = true
channel.Name = channelInfo.Name
}
// we can't join a channel unless we are using legacy tokens #651
if !channelInfo.IsMember && !b.legacy {
return fmt.Errorf("slack integration that matterbridge is using is not member of channel '%s', please add it manually", channelInfo.Name)
}
return nil
}
func (b *Bslack) Reload(cfg *bridge.Config) (string, error) {
return "", nil
}
func (b *Bslack) Send(msg config.Message) (string, error) {
// Too noisy to log like other events
if msg.Event != config.EventUserTyping {
b.Log.Debugf("=> Receiving %#v", msg)
}
msg.Text = helper.ClipMessage(msg.Text, messageLength, b.GetString("MessageClipped"))
msg.Text = b.replaceCodeFence(msg.Text)
// Make a action /me of the message
if msg.Event == config.EventUserAction {
msg.Text = "_" + msg.Text + "_"
}
// Use webhook to send the message
if b.GetString(outgoingWebhookConfig) != "" && b.GetString(tokenConfig) == "" {
return "", b.sendWebhook(msg)
}
return b.sendRTM(msg)
}
// sendWebhook uses the configured WebhookURL to send the message
func (b *Bslack) sendWebhook(msg config.Message) error {
// Skip events.
if msg.Event != "" {
return nil
}
if b.GetBool(useNickPrefixConfig) {
msg.Text = msg.Username + msg.Text
}
if msg.Extra != nil {
// This sends a message only if we received a config.EVENT_FILE_FAILURE_SIZE.
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
rmsg := rmsg // scopelint
iconURL := config.GetIconURL(&rmsg, b.GetString(iconURLConfig))
matterMessage := matterhook.OMessage{
IconURL: iconURL,
Channel: msg.Channel,
UserName: rmsg.Username,
Text: rmsg.Text,
}
if err := b.mh.Send(matterMessage); err != nil {
b.Log.Errorf("Failed to send message: %v", err)
}
}
// Webhook doesn't support file uploads, so we add the URL manually.
for _, f := range msg.Extra["file"] {
fi, ok := f.(config.FileInfo)
if !ok {
b.Log.Errorf("Received a file with unexpected content: %#v", f)
continue
}
if fi.URL != "" {
msg.Text += " " + fi.URL
}
}
}
// If we have native slack_attachments add them.
var attachs []slack.Attachment
for _, attach := range msg.Extra[sSlackAttachment] {
attachs = append(attachs, attach.([]slack.Attachment)...)
}
iconURL := config.GetIconURL(&msg, b.GetString(iconURLConfig))
matterMessage := matterhook.OMessage{
IconURL: iconURL,
Attachments: attachs,
Channel: msg.Channel,
UserName: msg.Username,
Text: msg.Text,
}
if msg.Avatar != "" {
matterMessage.IconURL = msg.Avatar
}
if err := b.mh.Send(matterMessage); err != nil {
b.Log.Errorf("Failed to send message via webhook: %#v", err)
return err
}
return nil
}
func (b *Bslack) sendRTM(msg config.Message) (string, error) {
// Handle channelmember messages.
if handled := b.handleGetChannelMembers(&msg); handled {
return "", nil
}
channelInfo, err := b.channels.getChannel(msg.Channel)
if err != nil {
return "", fmt.Errorf("could not send message: %v", err)
}
if msg.Event == config.EventUserTyping {
if b.GetBool("ShowUserTyping") {
b.rtm.SendMessage(b.rtm.NewTypingMessage(channelInfo.ID))
}
return "", nil
}
var handled bool
// Handle topic/purpose updates.
if handled, err = b.handleTopicOrPurpose(&msg, channelInfo); handled {
return "", err
}
// Handle prefix hint for unthreaded messages.
if msg.ParentNotFound() {
msg.ParentID = ""
msg.Text = fmt.Sprintf("[thread]: %s", msg.Text)
}
// Handle message deletions.
if handled, err = b.deleteMessage(&msg, channelInfo); handled {
return msg.ID, err
}
// Prepend nickname if configured.
if b.GetBool(useNickPrefixConfig) {
msg.Text = msg.Username + msg.Text
}
// Handle message edits.
if handled, err = b.editMessage(&msg, channelInfo); handled {
return msg.ID, err
}
// Upload a file if it exists.
if msg.Extra != nil {
extraMsgs := helper.HandleExtra(&msg, b.General)
for i := range extraMsgs {
rmsg := &extraMsgs[i]
rmsg.Text = rmsg.Username + rmsg.Text
_, err = b.postMessage(rmsg, channelInfo)
if err != nil {
b.Log.Error(err)
}
}
// Upload files if necessary (from Slack, Telegram or Mattermost).
b.uploadFile(&msg, channelInfo.ID)
}
// Post message.
return b.postMessage(&msg, channelInfo)
}
func (b *Bslack) updateTopicOrPurpose(msg *config.Message, channelInfo *slack.Channel) error {
var updateFunc func(channelID string, value string) (*slack.Channel, error)
incomingChangeType, text := b.extractTopicOrPurpose(msg.Text)
switch incomingChangeType {
case "topic":
updateFunc = b.rtm.SetTopicOfConversation
case "purpose":
updateFunc = b.rtm.SetPurposeOfConversation
default:
b.Log.Errorf("Unhandled type received from extractTopicOrPurpose: %s", incomingChangeType)
return nil
}
for {
_, err := updateFunc(channelInfo.ID, text)
if err == nil {
return nil
}
if err = handleRateLimit(b.Log, err); err != nil {
return err
}
}
return nil
}
// handles updating topic/purpose and determining whether to further propagate update messages.
func (b *Bslack) handleTopicOrPurpose(msg *config.Message, channelInfo *slack.Channel) (bool, error) {
if msg.Event != config.EventTopicChange {
return false, nil
}
if b.GetBool("SyncTopic") {
return true, b.updateTopicOrPurpose(msg, channelInfo)
}
// Pass along to normal message handlers.
if b.GetBool("ShowTopicChange") {
return false, nil
}
// Swallow message as handled no-op.
return true, nil
func (b *Bslack) Name() string {
return b.protocol + "." + b.origin
}
func (b *Bslack) deleteMessage(msg *config.Message, channelInfo *slack.Channel) (bool, error) {
if msg.Event != config.EventMsgDelete {
return false, nil
}
// Some protocols echo deletes, but with an empty ID.
if msg.ID == "" {
return true, nil
}
for {
_, _, err := b.rtm.DeleteMessage(channelInfo.ID, msg.ID)
if err == nil {
return true, nil
}
if err = handleRateLimit(b.Log, err); err != nil {
b.Log.Errorf("Failed to delete user message from Slack: %#v", err)
return true, err
}
}
func (b *Bslack) Protocol() string {
return b.protocol
}
func (b *Bslack) editMessage(msg *config.Message, channelInfo *slack.Channel) (bool, error) {
if msg.ID == "" {
return false, nil
}
messageOptions := b.prepareMessageOptions(msg)
for {
_, _, _, err := b.rtm.UpdateMessage(channelInfo.ID, msg.ID, messageOptions...)
if err == nil {
return true, nil
}
if err = handleRateLimit(b.Log, err); err != nil {
b.Log.Errorf("Failed to edit user message on Slack: %#v", err)
return true, err
}
}
func (b *Bslack) Origin() string {
return b.origin
}
func (b *Bslack) postMessage(msg *config.Message, channelInfo *slack.Channel) (string, error) {
// don't post empty messages
if msg.Text == "" {
return "", nil
func (b *Bslack) Send(msg config.Message) error {
flog.Debugf("Receiving %#v", msg)
if msg.FullOrigin == b.FullOrigin() {
return nil
}
messageOptions := b.prepareMessageOptions(msg)
for {
_, id, err := b.rtm.PostMessage(channelInfo.ID, messageOptions...)
if err == nil {
return id, nil
nick := config.GetNick(&msg, b.Config)
message := msg.Text
channel := msg.Channel
if b.Config.PrefixMessagesWithNick {
message = nick + " " + message
}
if !b.Config.UseAPI {
matterMessage := matterhook.OMessage{IconURL: b.Config.IconURL}
matterMessage.Channel = channel
matterMessage.UserName = nick
matterMessage.Type = ""
matterMessage.Text = message
err := b.mh.Send(matterMessage)
if err != nil {
flog.Info(err)
return err
}
return nil
}
schannel, err := b.getChannelByName(channel)
if err != nil {
return err
}
np := slack.NewPostMessageParameters()
if b.Config.PrefixMessagesWithNick == true {
np.AsUser = true
}
np.Username = nick
np.IconURL = config.GetIconURL(&msg, b.Config)
if msg.Avatar != "" {
np.IconURL = msg.Avatar
}
b.sc.PostMessage(schannel.ID, message, np)
if err = handleRateLimit(b.Log, err); err != nil {
b.Log.Errorf("Failed to sent user message to Slack: %#v", err)
return "", err
}
}
/*
newmsg := b.rtm.NewOutgoingMessage(message, schannel.ID)
b.rtm.SendMessage(newmsg)
*/
return nil
}
// uploadFile handles native upload of files
func (b *Bslack) uploadFile(msg *config.Message, channelID string) {
for _, f := range msg.Extra["file"] {
fi, ok := f.(config.FileInfo)
if !ok {
b.Log.Errorf("Received a file with unexpected content: %#v", f)
func (b *Bslack) getAvatar(user string) string {
var avatar string
if b.Users != nil {
for _, u := range b.Users {
if user == u.Name {
return u.Profile.Image48
}
}
}
return avatar
}
func (b *Bslack) getChannelByName(name string) (*slack.Channel, error) {
if b.channels == nil {
return nil, fmt.Errorf("%s: channel %s not found (no channels found)", b.FullOrigin(), name)
}
for _, channel := range b.channels {
if channel.Name == name {
return &channel, nil
}
}
return nil, fmt.Errorf("%s: channel %s not found", b.FullOrigin(), name)
}
func (b *Bslack) handleSlack() {
flog.Debugf("Choosing API based slack connection: %t", b.Config.UseAPI)
mchan := make(chan *MMMessage)
if b.Config.UseAPI {
go b.handleSlackClient(mchan)
} else {
go b.handleMatterHook(mchan)
}
time.Sleep(time.Second)
flog.Debug("Start listening for Slack messages")
for message := range mchan {
// do not send messages from ourself
if message.Username == b.si.User.Name {
continue
}
if msg.Text == fi.Comment {
msg.Text = ""
}
// Because the result of the UploadFile is slower than the MessageEvent from slack
// we can't match on the file ID yet, so we have to match on the filename too.
ts := time.Now()
b.Log.Debugf("Adding file %s to cache at %s with timestamp", fi.Name, ts.String())
b.cache.Add("filename"+fi.Name, ts)
initialComment := fmt.Sprintf("File from %s", msg.Username)
if fi.Comment != "" {
initialComment += fmt.Sprintf(" with comment: %s", fi.Comment)
}
res, err := b.sc.UploadFile(slack.FileUploadParameters{
Reader: bytes.NewReader(*fi.Data),
Filename: fi.Name,
Channels: []string{channelID},
InitialComment: initialComment,
ThreadTimestamp: msg.ParentID,
})
if err != nil {
b.Log.Errorf("uploadfile %#v", err)
return
}
if res.ID != "" {
b.Log.Debugf("Adding file ID %s to cache with timestamp %s", res.ID, ts.String())
b.cache.Add("file"+res.ID, ts)
texts := strings.Split(message.Text, "\n")
for _, text := range texts {
flog.Debugf("Sending message from %s on %s to gateway", message.Username, b.FullOrigin())
b.Remote <- config.Message{Text: text, Username: message.Username, Channel: message.Channel, Origin: b.origin, Protocol: b.protocol, FullOrigin: b.FullOrigin(), Avatar: b.getAvatar(message.Username)}
}
}
}
func (b *Bslack) prepareMessageOptions(msg *config.Message) []slack.MsgOption {
params := slack.NewPostMessageParameters()
if b.GetBool(useNickPrefixConfig) {
params.AsUser = true
}
params.Username = msg.Username
params.LinkNames = 1 // replace mentions
params.IconURL = config.GetIconURL(msg, b.GetString(iconURLConfig))
params.ThreadTimestamp = msg.ParentID
if msg.Avatar != "" {
params.IconURL = msg.Avatar
}
var attachments []slack.Attachment
// add file attachments
attachments = append(attachments, b.createAttach(msg.Extra)...)
// add slack attachments (from another slack bridge)
if msg.Extra != nil {
for _, attach := range msg.Extra[sSlackAttachment] {
attachments = append(attachments, attach.([]slack.Attachment)...)
func (b *Bslack) handleSlackClient(mchan chan *MMMessage) {
count := 0
for msg := range b.rtm.IncomingEvents {
switch ev := msg.Data.(type) {
case *slack.MessageEvent:
// ignore first message
if count > 0 {
flog.Debugf("Receiving from slackclient %#v", ev)
//ev.ReplyTo
channel, err := b.rtm.GetChannelInfo(ev.Channel)
if err != nil {
continue
}
user, err := b.rtm.GetUserInfo(ev.User)
if err != nil {
continue
}
m := &MMMessage{}
m.Username = user.Name
m.Channel = channel.Name
m.Text = ev.Text
m.Raw = ev
mchan <- m
}
count++
case *slack.OutgoingErrorEvent:
flog.Debugf("%#v", ev.Error())
case *slack.ConnectedEvent:
b.channels = ev.Info.Channels
b.si = ev.Info
b.Users, _ = b.sc.GetUsers()
case *slack.InvalidAuthEvent:
flog.Fatalf("Invalid Token %#v", ev)
default:
}
}
var opts []slack.MsgOption
opts = append(opts,
// provide regular text field (fallback used in Slack notifications, etc.)
slack.MsgOptionText(msg.Text, false),
// add a callback ID so we can see we created it
slack.MsgOptionBlocks(slack.NewSectionBlock(
slack.NewTextBlockObject(slack.MarkdownType, msg.Text, false, false),
nil, nil,
slack.SectionBlockOptionBlockID("matterbridge_"+b.uuid),
)),
slack.MsgOptionEnableLinkUnfurl(),
)
opts = append(opts, slack.MsgOptionAttachments(attachments...))
opts = append(opts, slack.MsgOptionPostMessageParameters(params))
return opts
}
func (b *Bslack) createAttach(extra map[string][]interface{}) []slack.Attachment {
var attachements []slack.Attachment
for _, v := range extra["attachments"] {
entry := v.(map[string]interface{})
s := slack.Attachment{
Fallback: extractStringField(entry, "fallback"),
Color: extractStringField(entry, "color"),
Pretext: extractStringField(entry, "pretext"),
AuthorName: extractStringField(entry, "author_name"),
AuthorLink: extractStringField(entry, "author_link"),
AuthorIcon: extractStringField(entry, "author_icon"),
Title: extractStringField(entry, "title"),
TitleLink: extractStringField(entry, "title_link"),
Text: extractStringField(entry, "text"),
ImageURL: extractStringField(entry, "image_url"),
ThumbURL: extractStringField(entry, "thumb_url"),
Footer: extractStringField(entry, "footer"),
FooterIcon: extractStringField(entry, "footer_icon"),
}
attachements = append(attachements, s)
func (b *Bslack) handleMatterHook(mchan chan *MMMessage) {
for {
message := b.mh.Receive()
flog.Debugf("receiving from matterhook (slack) %#v", message)
m := &MMMessage{}
m.Username = message.UserName
m.Text = message.Text
m.Channel = message.ChannelName
mchan <- m
}
return attachements
}
func extractStringField(data map[string]interface{}, field string) string {
if rawValue, found := data[field]; found {
if value, ok := rawValue.(string); ok {
return value
}
}
return ""
}

View File

@ -1,343 +0,0 @@
package bslack
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/42wim/matterbridge/bridge/config"
"github.com/sirupsen/logrus"
"github.com/slack-go/slack"
)
const minimumRefreshInterval = 10 * time.Second
type users struct {
log *logrus.Entry
sc *slack.Client
users map[string]*slack.User
usersMutex sync.RWMutex
usersSyncPoints map[string]chan struct{}
refreshInProgress bool
earliestRefresh time.Time
refreshMutex sync.Mutex
}
func newUserManager(log *logrus.Entry, sc *slack.Client) *users {
return &users{
log: log,
sc: sc,
users: make(map[string]*slack.User),
usersSyncPoints: make(map[string]chan struct{}),
earliestRefresh: time.Now(),
}
}
func (b *users) getUser(id string) *slack.User {
b.usersMutex.RLock()
user, ok := b.users[id]
b.usersMutex.RUnlock()
if ok {
return user
}
b.populateUser(id)
b.usersMutex.RLock()
defer b.usersMutex.RUnlock()
return b.users[id]
}
func (b *users) getUsername(id string) string {
if user := b.getUser(id); user != nil {
if user.Profile.DisplayName != "" {
return user.Profile.DisplayName
}
return user.Name
}
b.log.Warnf("Could not find user with ID '%s'", id)
return ""
}
func (b *users) getAvatar(id string) string {
if user := b.getUser(id); user != nil {
return user.Profile.Image48
}
return ""
}
func (b *users) populateUser(userID string) {
for {
b.usersMutex.Lock()
_, exists := b.users[userID]
if exists {
// already in cache
b.usersMutex.Unlock()
return
}
if syncPoint, ok := b.usersSyncPoints[userID]; ok {
// Another goroutine is already populating this user for us so wait on it to finish.
b.usersMutex.Unlock()
<-syncPoint
// We do not return and iterate again to check that the entry does indeed exist
// in case the previous query failed for some reason.
} else {
b.usersSyncPoints[userID] = make(chan struct{})
defer func() {
// Wake up any waiting goroutines and remove the synchronization point.
close(b.usersSyncPoints[userID])
delete(b.usersSyncPoints, userID)
}()
break
}
}
// Do not hold the lock while fetching information from Slack
// as this might take an unbounded amount of time.
b.usersMutex.Unlock()
user, err := b.sc.GetUserInfo(userID)
if err != nil {
b.log.Debugf("GetUserInfo failed for %v: %v", userID, err)
return
}
b.usersMutex.Lock()
defer b.usersMutex.Unlock()
// Register user information.
b.users[userID] = user
}
func (b *users) invalidateUser(userID string) {
b.usersMutex.Lock()
defer b.usersMutex.Unlock()
delete(b.users, userID)
}
func (b *users) populateUsers(wait bool) {
b.refreshMutex.Lock()
if !wait && (time.Now().Before(b.earliestRefresh) || b.refreshInProgress) {
b.log.Debugf("Not refreshing user list as it was done less than %v ago.", minimumRefreshInterval)
b.refreshMutex.Unlock()
return
}
for b.refreshInProgress {
b.refreshMutex.Unlock()
time.Sleep(time.Second)
b.refreshMutex.Lock()
}
b.refreshInProgress = true
b.refreshMutex.Unlock()
newUsers := map[string]*slack.User{}
pagination := b.sc.GetUsersPaginated(slack.GetUsersOptionLimit(200))
count := 0
for {
var err error
pagination, err = pagination.Next(context.Background())
time.Sleep(time.Second)
if err != nil {
if pagination.Done(err) {
break
}
if err = handleRateLimit(b.log, err); err != nil {
b.log.Errorf("Could not retrieve users: %#v", err)
return
}
continue
}
for i := range pagination.Users {
newUsers[pagination.Users[i].ID] = &pagination.Users[i]
}
b.log.Debugf("getting %d users", len(pagination.Users))
count++
// more > 2000 users, slack will complain and ratelimit. break
if count > 10 {
b.log.Info("Large slack detected > 2000 users, skipping loading complete userlist.")
break
}
}
b.usersMutex.Lock()
defer b.usersMutex.Unlock()
b.users = newUsers
b.refreshMutex.Lock()
defer b.refreshMutex.Unlock()
b.earliestRefresh = time.Now().Add(minimumRefreshInterval)
b.refreshInProgress = false
}
type channels struct {
log *logrus.Entry
sc *slack.Client
channelsByID map[string]*slack.Channel
channelsByName map[string]*slack.Channel
channelsMutex sync.RWMutex
channelMembers map[string][]string
channelMembersMutex sync.RWMutex
refreshInProgress bool
earliestRefresh time.Time
refreshMutex sync.Mutex
}
func newChannelManager(log *logrus.Entry, sc *slack.Client) *channels {
return &channels{
log: log,
sc: sc,
channelsByID: make(map[string]*slack.Channel),
channelsByName: make(map[string]*slack.Channel),
earliestRefresh: time.Now(),
}
}
func (b *channels) getChannel(channel string) (*slack.Channel, error) {
if strings.HasPrefix(channel, "ID:") {
return b.getChannelByID(strings.TrimPrefix(channel, "ID:"))
}
return b.getChannelByName(channel)
}
func (b *channels) getChannelByName(name string) (*slack.Channel, error) {
return b.getChannelBy(name, b.channelsByName)
}
func (b *channels) getChannelByID(id string) (*slack.Channel, error) {
return b.getChannelBy(id, b.channelsByID)
}
func (b *channels) getChannelBy(lookupKey string, lookupMap map[string]*slack.Channel) (*slack.Channel, error) {
b.channelsMutex.RLock()
defer b.channelsMutex.RUnlock()
if channel, ok := lookupMap[lookupKey]; ok {
return channel, nil
}
return nil, fmt.Errorf("channel %s not found", lookupKey)
}
func (b *channels) getChannelMembers(users *users) config.ChannelMembers {
b.channelMembersMutex.RLock()
defer b.channelMembersMutex.RUnlock()
membersInfo := config.ChannelMembers{}
for channelID, members := range b.channelMembers {
for _, member := range members {
channelName := ""
userName := ""
userNick := ""
user := users.getUser(member)
if user != nil {
userName = user.Name
userNick = user.Profile.DisplayName
}
channel, _ := b.getChannelByID(channelID)
if channel != nil {
channelName = channel.Name
}
memberInfo := config.ChannelMember{
Username: userName,
Nick: userNick,
UserID: member,
ChannelID: channelID,
ChannelName: channelName,
}
membersInfo = append(membersInfo, memberInfo)
}
}
return membersInfo
}
func (b *channels) registerChannel(channel slack.Channel) {
b.channelsMutex.Lock()
defer b.channelsMutex.Unlock()
b.channelsByID[channel.ID] = &channel
b.channelsByName[channel.Name] = &channel
}
func (b *channels) populateChannels(wait bool) {
b.refreshMutex.Lock()
if !wait && (time.Now().Before(b.earliestRefresh) || b.refreshInProgress) {
b.log.Debugf("Not refreshing channel list as it was done less than %v seconds ago.", minimumRefreshInterval)
b.refreshMutex.Unlock()
return
}
for b.refreshInProgress {
b.refreshMutex.Unlock()
time.Sleep(time.Second)
b.refreshMutex.Lock()
}
b.refreshInProgress = true
b.refreshMutex.Unlock()
newChannelsByID := map[string]*slack.Channel{}
newChannelsByName := map[string]*slack.Channel{}
newChannelMembers := make(map[string][]string)
// We only retrieve public and private channels, not IMs
// and MPIMs as those do not have a channel name.
queryParams := &slack.GetConversationsParameters{
ExcludeArchived: true,
Types: []string{"public_channel,private_channel"},
Limit: 1000,
}
for {
channels, nextCursor, err := b.sc.GetConversations(queryParams)
if err != nil {
if err = handleRateLimit(b.log, err); err != nil {
b.log.Errorf("Could not retrieve channels: %#v", err)
return
}
continue
}
for i := range channels {
newChannelsByID[channels[i].ID] = &channels[i]
newChannelsByName[channels[i].Name] = &channels[i]
// also find all the members in every channel
// comment for now, issues on big slacks
/*
members, err := b.getUsersInConversation(channels[i].ID)
if err != nil {
if err = b.handleRateLimit(err); err != nil {
b.Log.Errorf("Could not retrieve channel members: %#v", err)
return
}
continue
}
newChannelMembers[channels[i].ID] = members
*/
}
if nextCursor == "" {
break
}
queryParams.Cursor = nextCursor
}
b.channelsMutex.Lock()
defer b.channelsMutex.Unlock()
b.channelsByID = newChannelsByID
b.channelsByName = newChannelsByName
b.channelMembersMutex.Lock()
defer b.channelMembersMutex.Unlock()
b.channelMembers = newChannelMembers
b.refreshMutex.Lock()
defer b.refreshMutex.Unlock()
b.earliestRefresh = time.Now().Add(minimumRefreshInterval)
b.refreshInProgress = false
}

View File

@ -1,169 +0,0 @@
package bsshchat
import (
"bufio"
"io"
"strings"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/shazow/ssh-chat/sshd"
)
type Bsshchat struct {
r *bufio.Scanner
w io.WriteCloser
*bridge.Config
}
func New(cfg *bridge.Config) bridge.Bridger {
return &Bsshchat{Config: cfg}
}
func (b *Bsshchat) Connect() error {
b.Log.Infof("Connecting %s", b.GetString("Server"))
// connHandler will be called by 'sshd.ConnectShell()' below
// once the connection is established in order to handle it.
connErr := make(chan error, 1) // Needs to be buffered.
connSignal := make(chan struct{})
connHandler := func(r io.Reader, w io.WriteCloser) error {
b.r = bufio.NewScanner(r)
b.r.Scan()
b.w = w
if _, err := b.w.Write([]byte("/theme mono\r\n/quiet\r\n")); err != nil {
return err
}
close(connSignal) // Connection is established so we can signal the success.
return b.handleSSHChat()
}
go func() {
// As a successful connection will result in this returning after the Connection
// method has already returned point we NEED to have a buffered channel to still
// be able to write.
connErr <- sshd.ConnectShell(b.GetString("Server"), b.GetString("Nick"), connHandler)
}()
select {
case err := <-connErr:
b.Log.Error("Connection failed")
return err
case <-connSignal:
}
b.Log.Info("Connection succeeded")
return nil
}
func (b *Bsshchat) Disconnect() error {
return nil
}
func (b *Bsshchat) JoinChannel(channel config.ChannelInfo) error {
return nil
}
func (b *Bsshchat) Send(msg config.Message) (string, error) {
// ignore delete messages
if msg.Event == config.EventMsgDelete {
return "", nil
}
b.Log.Debugf("=> Receiving %#v", msg)
if msg.Extra != nil {
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
if _, err := b.w.Write([]byte(rmsg.Username + rmsg.Text + "\r\n")); err != nil {
b.Log.Errorf("Could not send extra message: %#v", err)
}
}
if len(msg.Extra["file"]) > 0 {
return b.handleUploadFile(&msg)
}
}
_, err := b.w.Write([]byte(msg.Username + msg.Text + "\r\n"))
return "", err
}
/*
func (b *Bsshchat) sshchatKeepAlive() chan bool {
done := make(chan bool)
go func() {
ticker := time.NewTicker(90 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
b.Log.Debugf("PING")
err := b.xc.PingC2S("", "")
if err != nil {
b.Log.Debugf("PING failed %#v", err)
}
case <-done:
return
}
}
}()
return done
}
*/
func stripPrompt(s string) string {
pos := strings.LastIndex(s, "\033[K")
if pos < 0 {
return s
}
return s[pos+3:]
}
func (b *Bsshchat) handleSSHChat() error {
/*
done := b.sshchatKeepAlive()
defer close(done)
*/
wait := true
for {
if b.r.Scan() {
// ignore messages from ourselves
if !strings.Contains(b.r.Text(), "\033[K") {
continue
}
if strings.Contains(b.r.Text(), "Rate limiting is in effect") {
continue
}
// skip our own messages
if !strings.HasPrefix(b.r.Text(), "["+b.GetString("Nick")+"] \x1b") {
continue
}
res := strings.Split(stripPrompt(b.r.Text()), ":")
if res[0] == "-> Set theme" {
wait = false
b.Log.Debugf("mono found, allowing")
continue
}
if !wait {
b.Log.Debugf("<= Message %#v", res)
rmsg := config.Message{Username: res[0], Text: strings.TrimSpace(strings.Join(res[1:], ":")), Channel: "sshchat", Account: b.Account, UserID: "nick"}
b.Remote <- rmsg
}
}
}
}
func (b *Bsshchat) handleUploadFile(msg *config.Message) (string, error) {
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
if fi.Comment != "" {
msg.Text += fi.Comment + ": "
}
if fi.URL != "" {
msg.Text = fi.URL
if fi.Comment != "" {
msg.Text = fi.Comment + ": " + fi.URL
}
}
if _, err := b.w.Write([]byte(msg.Username + msg.Text + "\r\n")); err != nil {
b.Log.Errorf("Could not send file message: %#v", err)
}
}
return "", nil
}

View File

@ -1,126 +0,0 @@
package bsteam
import (
"fmt"
"strconv"
"github.com/42wim/matterbridge/bridge/config"
"github.com/Philipp15b/go-steam"
"github.com/Philipp15b/go-steam/protocol/steamlang"
)
func (b *Bsteam) handleChatMsg(e *steam.ChatMsgEvent) {
b.Log.Debugf("Receiving ChatMsgEvent: %#v", e)
b.Log.Debugf("<= Sending message from %s on %s to gateway", b.getNick(e.ChatterId), b.Account)
var channel int64
if e.ChatRoomId == 0 {
channel = int64(e.ChatterId)
} else {
// for some reason we have to remove 0x18000000000000
// TODO
// https://github.com/42wim/matterbridge/pull/630#discussion_r238102751
// channel = int64(e.ChatRoomId) & 0xfffffffffffff
channel = int64(e.ChatRoomId) - 0x18000000000000
}
msg := config.Message{
Username: b.getNick(e.ChatterId),
Text: e.Message,
Channel: strconv.FormatInt(channel, 10),
Account: b.Account,
UserID: strconv.FormatInt(int64(e.ChatterId), 10),
}
b.Remote <- msg
}
func (b *Bsteam) handleEvents() {
myLoginInfo := &steam.LogOnDetails{
Username: b.GetString("Login"),
Password: b.GetString("Password"),
AuthCode: b.GetString("AuthCode"),
}
// TODO Attempt to read existing auth hash to avoid steam guard.
// Maybe works
//myLoginInfo.SentryFileHash, _ = ioutil.ReadFile("sentry")
for event := range b.c.Events() {
switch e := event.(type) {
case *steam.ChatMsgEvent:
b.handleChatMsg(e)
case *steam.PersonaStateEvent:
b.Log.Debugf("PersonaStateEvent: %#v\n", e)
b.Lock()
b.userMap[e.FriendId] = e.Name
b.Unlock()
case *steam.ConnectedEvent:
b.c.Auth.LogOn(myLoginInfo)
case *steam.MachineAuthUpdateEvent:
// TODO sentry files for 2 auth
/*
b.Log.Info("authupdate", e)
b.Log.Info("hash", e.Hash)
ioutil.WriteFile("sentry", e.Hash, 0666)
*/
case *steam.LogOnFailedEvent:
b.Log.Info("Logon failed", e)
err := b.handleLogOnFailed(e, myLoginInfo)
if err != nil {
b.Log.Error(err)
return
}
case *steam.LoggedOnEvent:
b.Log.Debugf("LoggedOnEvent: %#v", e)
b.connected <- struct{}{}
b.Log.Debugf("setting online")
b.c.Social.SetPersonaState(steamlang.EPersonaState_Online)
case *steam.DisconnectedEvent:
b.Log.Info("Disconnected")
b.Log.Info("Attempting to reconnect...")
b.c.Connect()
case steam.FatalErrorEvent:
b.Log.Errorf("steam FatalErrorEvent: %#v", e)
default:
b.Log.Debugf("unknown event %#v", e)
}
}
}
func (b *Bsteam) handleLogOnFailed(e *steam.LogOnFailedEvent, myLoginInfo *steam.LogOnDetails) error {
switch e.Result {
case steamlang.EResult_AccountLoginDeniedNeedTwoFactor:
b.Log.Info("Steam guard isn't letting me in! Enter 2FA code:")
var code string
fmt.Scanf("%s", &code)
// TODO https://github.com/42wim/matterbridge/pull/630#discussion_r238103978
myLoginInfo.TwoFactorCode = code
case steamlang.EResult_AccountLogonDenied:
b.Log.Info("Steam guard isn't letting me in! Enter auth code:")
var code string
fmt.Scanf("%s", &code)
// TODO https://github.com/42wim/matterbridge/pull/630#discussion_r238103978
myLoginInfo.AuthCode = code
case steamlang.EResult_InvalidLoginAuthCode:
return fmt.Errorf("Steam guard: invalid login auth code: %#v ", e.Result)
default:
return fmt.Errorf("LogOnFailedEvent: %#v ", e.Result)
// TODO: Handle EResult_InvalidLoginAuthCode
}
return nil
}
// handleFileInfo handles config.FileInfo and adds correct file comment or URL to msg.Text.
// Returns error if cast fails.
func (b *Bsteam) handleFileInfo(msg *config.Message, f interface{}) error {
if _, ok := f.(config.FileInfo); !ok {
return fmt.Errorf("handleFileInfo cast failed %#v", f)
}
fi := f.(config.FileInfo)
if fi.Comment != "" {
msg.Text += fi.Comment + ": "
}
if fi.URL != "" {
msg.Text = fi.URL
if fi.Comment != "" {
msg.Text = fi.Comment + ": " + fi.URL
}
}
return nil
}

View File

@ -1,95 +0,0 @@
package bsteam
import (
"fmt"
"sync"
"time"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/Philipp15b/go-steam"
"github.com/Philipp15b/go-steam/protocol/steamlang"
"github.com/Philipp15b/go-steam/steamid"
)
type Bsteam struct {
c *steam.Client
connected chan struct{}
userMap map[steamid.SteamId]string
sync.RWMutex
*bridge.Config
}
func New(cfg *bridge.Config) bridge.Bridger {
b := &Bsteam{Config: cfg}
b.userMap = make(map[steamid.SteamId]string)
b.connected = make(chan struct{})
return b
}
func (b *Bsteam) Connect() error {
b.Log.Info("Connecting")
b.c = steam.NewClient()
go b.handleEvents()
go b.c.Connect()
select {
case <-b.connected:
b.Log.Info("Connection succeeded")
case <-time.After(time.Second * 30):
return fmt.Errorf("connection timed out")
}
return nil
}
func (b *Bsteam) Disconnect() error {
b.c.Disconnect()
return nil
}
func (b *Bsteam) JoinChannel(channel config.ChannelInfo) error {
id, err := steamid.NewId(channel.Name)
if err != nil {
return err
}
b.c.Social.JoinChat(id)
return nil
}
func (b *Bsteam) Send(msg config.Message) (string, error) {
// ignore delete messages
if msg.Event == config.EventMsgDelete {
return "", nil
}
id, err := steamid.NewId(msg.Channel)
if err != nil {
return "", err
}
// Handle files
if msg.Extra != nil {
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
b.c.Social.SendMessage(id, steamlang.EChatEntryType_ChatMsg, rmsg.Username+rmsg.Text)
}
for i := range msg.Extra["file"] {
if err := b.handleFileInfo(&msg, msg.Extra["file"][i]); err != nil {
b.Log.Error(err)
}
b.c.Social.SendMessage(id, steamlang.EChatEntryType_ChatMsg, msg.Username+msg.Text)
}
return "", nil
}
b.c.Social.SendMessage(id, steamlang.EChatEntryType_ChatMsg, msg.Username+msg.Text)
return "", nil
}
func (b *Bsteam) getNick(id steamid.SteamId) string {
b.RLock()
defer b.RUnlock()
if name, ok := b.userMap[id]; ok {
return name
}
return "unknown"
}

View File

@ -1,476 +0,0 @@
package btelegram
import (
"html"
"path/filepath"
"strconv"
"strings"
"unicode/utf16"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (b *Btelegram) handleUpdate(rmsg *config.Message, message, posted, edited *tgbotapi.Message) *tgbotapi.Message {
// handle channels
if posted != nil {
message = posted
rmsg.Text = message.Text
}
// edited channel message
if edited != nil && !b.GetBool("EditDisable") {
message = edited
rmsg.Text = rmsg.Text + message.Text + b.GetString("EditSuffix")
}
return message
}
// handleChannels checks if it's a channel message and if the message is a new or edited messages
func (b *Btelegram) handleChannels(rmsg *config.Message, message *tgbotapi.Message, update tgbotapi.Update) *tgbotapi.Message {
return b.handleUpdate(rmsg, message, update.ChannelPost, update.EditedChannelPost)
}
// handleGroups checks if it's a group message and if the message is a new or edited messages
func (b *Btelegram) handleGroups(rmsg *config.Message, message *tgbotapi.Message, update tgbotapi.Update) *tgbotapi.Message {
return b.handleUpdate(rmsg, message, update.Message, update.EditedMessage)
}
// handleForwarded handles forwarded messages
func (b *Btelegram) handleForwarded(rmsg *config.Message, message *tgbotapi.Message) {
if message.ForwardDate == 0 {
return
}
if message.ForwardFrom == nil {
rmsg.Text = "Forwarded from " + unknownUser + ": " + rmsg.Text
return
}
usernameForward := ""
if b.GetBool("UseFirstName") {
usernameForward = message.ForwardFrom.FirstName
}
if usernameForward == "" {
usernameForward = message.ForwardFrom.UserName
if usernameForward == "" {
usernameForward = message.ForwardFrom.FirstName
}
}
if usernameForward == "" {
usernameForward = unknownUser
}
rmsg.Text = "Forwarded from " + usernameForward + ": " + rmsg.Text
}
// handleQuoting handles quoting of previous messages
func (b *Btelegram) handleQuoting(rmsg *config.Message, message *tgbotapi.Message) {
if message.ReplyToMessage != nil {
usernameReply := ""
if message.ReplyToMessage.From != nil {
if b.GetBool("UseFirstName") {
usernameReply = message.ReplyToMessage.From.FirstName
}
if usernameReply == "" {
usernameReply = message.ReplyToMessage.From.UserName
if usernameReply == "" {
usernameReply = message.ReplyToMessage.From.FirstName
}
}
}
if usernameReply == "" {
usernameReply = unknownUser
}
if !b.GetBool("QuoteDisable") {
rmsg.Text = b.handleQuote(rmsg.Text, usernameReply, message.ReplyToMessage.Text)
}
}
}
// handleUsername handles the correct setting of the username
func (b *Btelegram) handleUsername(rmsg *config.Message, message *tgbotapi.Message) {
if message.From != nil {
rmsg.UserID = strconv.FormatInt(message.From.ID, 10)
if b.GetBool("UseFirstName") {
rmsg.Username = message.From.FirstName
}
if rmsg.Username == "" {
rmsg.Username = message.From.UserName
if rmsg.Username == "" {
rmsg.Username = message.From.FirstName
}
}
// only download avatars if we have a place to upload them (configured mediaserver)
if b.General.MediaServerUpload != "" || (b.General.MediaServerDownload != "" && b.General.MediaDownloadPath != "") {
b.handleDownloadAvatar(message.From.ID, rmsg.Channel)
}
}
// if we really didn't find a username, set it to unknown
if rmsg.Username == "" {
rmsg.Username = unknownUser
}
}
func (b *Btelegram) handleRecv(updates <-chan tgbotapi.Update) {
for update := range updates {
b.Log.Debugf("== Receiving event: %#v", update.Message)
if update.Message == nil && update.ChannelPost == nil &&
update.EditedMessage == nil && update.EditedChannelPost == nil {
b.Log.Error("Getting nil messages, this shouldn't happen.")
continue
}
var message *tgbotapi.Message
rmsg := config.Message{Account: b.Account, Extra: make(map[string][]interface{})}
// handle channels
message = b.handleChannels(&rmsg, message, update)
// handle groups
message = b.handleGroups(&rmsg, message, update)
if message == nil {
b.Log.Error("message is nil, this shouldn't happen.")
continue
}
// set the ID's from the channel or group message
rmsg.ID = strconv.Itoa(message.MessageID)
rmsg.Channel = strconv.FormatInt(message.Chat.ID, 10)
// handle username
b.handleUsername(&rmsg, message)
// handle any downloads
err := b.handleDownload(&rmsg, message)
if err != nil {
b.Log.Errorf("download failed: %s", err)
}
// handle forwarded messages
b.handleForwarded(&rmsg, message)
// quote the previous message
b.handleQuoting(&rmsg, message)
// handle entities (adding URLs)
b.handleEntities(&rmsg, message)
if rmsg.Text != "" || len(rmsg.Extra) > 0 {
rmsg.Text = helper.RemoveEmptyNewLines(rmsg.Text)
// channels don't have (always?) user information. see #410
if message.From != nil {
rmsg.Avatar = helper.GetAvatar(b.avatarMap, strconv.FormatInt(message.From.ID, 10), b.General)
}
b.Log.Debugf("<= Sending message from %s on %s to gateway", rmsg.Username, b.Account)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}
}
}
// handleDownloadAvatar downloads the avatar of userid from channel
// sends a EVENT_AVATAR_DOWNLOAD message to the gateway if successful.
// logs an error message if it fails
func (b *Btelegram) handleDownloadAvatar(userid int64, channel string) {
rmsg := config.Message{
Username: "system",
Text: "avatar",
Channel: channel,
Account: b.Account,
UserID: strconv.FormatInt(userid, 10),
Event: config.EventAvatarDownload,
Extra: make(map[string][]interface{}),
}
if _, ok := b.avatarMap[strconv.FormatInt(userid, 10)]; ok {
return
}
photos, err := b.c.GetUserProfilePhotos(tgbotapi.UserProfilePhotosConfig{UserID: userid, Limit: 1})
if err != nil {
b.Log.Errorf("Userprofile download failed for %#v %s", userid, err)
}
if len(photos.Photos) > 0 {
photo := photos.Photos[0][0]
url := b.getFileDirectURL(photo.FileID)
name := strconv.FormatInt(userid, 10) + ".png"
b.Log.Debugf("trying to download %#v fileid %#v with size %#v", name, photo.FileID, photo.FileSize)
err := helper.HandleDownloadSize(b.Log, &rmsg, name, int64(photo.FileSize), b.General)
if err != nil {
b.Log.Error(err)
return
}
data, err := helper.DownloadFile(url)
if err != nil {
b.Log.Errorf("download %s failed %#v", url, err)
return
}
helper.HandleDownloadData(b.Log, &rmsg, name, rmsg.Text, "", data, b.General)
b.Remote <- rmsg
}
}
func (b *Btelegram) maybeConvertTgs(name *string, data *[]byte) {
format := b.GetString("MediaConvertTgs")
if helper.SupportsFormat(format) {
b.Log.Debugf("Format supported by %s, converting %v", helper.LottieBackend(), name)
} else {
// Otherwise, no conversion was requested. Trying to run the usual webp
// converter would fail, because '.tgs.webp' is actually a gzipped JSON
// file, and has nothing to do with WebP.
return
}
err := helper.ConvertTgsToX(data, format, b.Log)
if err != nil {
b.Log.Errorf("conversion failed: %v", err)
} else {
*name = strings.Replace(*name, "tgs.webp", format, 1)
}
}
func (b *Btelegram) maybeConvertWebp(name *string, data *[]byte) {
if b.GetBool("MediaConvertWebPToPNG") {
b.Log.Debugf("WebP to PNG conversion enabled, converting %v", name)
err := helper.ConvertWebPToPNG(data)
if err != nil {
b.Log.Errorf("conversion failed: %v", err)
} else {
*name = strings.Replace(*name, ".webp", ".png", 1)
}
}
}
// handleDownloadFile handles file download
func (b *Btelegram) handleDownload(rmsg *config.Message, message *tgbotapi.Message) error {
size := 0
var url, name, text string
switch {
case message.Sticker != nil:
text, name, url = b.getDownloadInfo(message.Sticker.FileID, ".webp", true)
size = message.Sticker.FileSize
case message.Voice != nil:
text, name, url = b.getDownloadInfo(message.Voice.FileID, ".ogg", true)
size = message.Voice.FileSize
case message.Video != nil:
text, name, url = b.getDownloadInfo(message.Video.FileID, "", true)
size = message.Video.FileSize
case message.Audio != nil:
text, name, url = b.getDownloadInfo(message.Audio.FileID, "", true)
size = message.Audio.FileSize
case message.Document != nil:
_, _, url = b.getDownloadInfo(message.Document.FileID, "", false)
size = message.Document.FileSize
name = message.Document.FileName
text = " " + message.Document.FileName + " : " + url
case message.Photo != nil:
photos := message.Photo
size = photos[len(photos)-1].FileSize
text, name, url = b.getDownloadInfo(photos[len(photos)-1].FileID, "", true)
}
// if name is empty we didn't match a thing to download
if name == "" {
return nil
}
// use the URL instead of native upload
if b.GetBool("UseInsecureURL") {
b.Log.Debugf("Setting message text to :%s", text)
rmsg.Text += text
return nil
}
// if we have a file attached, download it (in memory) and put a pointer to it in msg.Extra
err := helper.HandleDownloadSize(b.Log, rmsg, name, int64(size), b.General)
if err != nil {
return err
}
data, err := helper.DownloadFile(url)
if err != nil {
return err
}
if strings.HasSuffix(name, ".tgs.webp") {
b.maybeConvertTgs(&name, data)
} else if strings.HasSuffix(name, ".webp") {
b.maybeConvertWebp(&name, data)
}
// rename .oga to .ogg https://github.com/42wim/matterbridge/issues/906#issuecomment-741793512
if strings.HasSuffix(name, ".oga") && message.Audio != nil {
name = strings.Replace(name, ".oga", ".ogg", 1)
}
helper.HandleDownloadData(b.Log, rmsg, name, message.Caption, "", data, b.General)
return nil
}
func (b *Btelegram) getDownloadInfo(id string, suffix string, urlpart bool) (string, string, string) {
url := b.getFileDirectURL(id)
name := ""
if urlpart {
urlPart := strings.Split(url, "/")
name = urlPart[len(urlPart)-1]
}
if suffix != "" && !strings.HasSuffix(name, suffix) {
name += suffix
}
text := " " + url
return text, name, url
}
// handleDelete handles message deleting
func (b *Btelegram) handleDelete(msg *config.Message, chatid int64) (string, error) {
if msg.ID == "" {
return "", nil
}
msgid, err := strconv.Atoi(msg.ID)
if err != nil {
return "", err
}
cfg := tgbotapi.NewDeleteMessage(chatid, msgid)
_, err = b.c.Send(cfg)
return "", err
}
// handleEdit handles message editing.
func (b *Btelegram) handleEdit(msg *config.Message, chatid int64) (string, error) {
msgid, err := strconv.Atoi(msg.ID)
if err != nil {
return "", err
}
if strings.ToLower(b.GetString("MessageFormat")) == HTMLNick {
b.Log.Debug("Using mode HTML - nick only")
msg.Text = html.EscapeString(msg.Text)
}
m := tgbotapi.NewEditMessageText(chatid, msgid, msg.Username+msg.Text)
switch b.GetString("MessageFormat") {
case HTMLFormat:
b.Log.Debug("Using mode HTML")
m.ParseMode = tgbotapi.ModeHTML
case "Markdown":
b.Log.Debug("Using mode markdown")
m.ParseMode = tgbotapi.ModeMarkdown
case MarkdownV2:
b.Log.Debug("Using mode MarkdownV2")
m.ParseMode = MarkdownV2
}
if strings.ToLower(b.GetString("MessageFormat")) == HTMLNick {
b.Log.Debug("Using mode HTML - nick only")
m.ParseMode = tgbotapi.ModeHTML
}
_, err = b.c.Send(m)
if err != nil {
return "", err
}
return "", nil
}
// handleUploadFile handles native upload of files
func (b *Btelegram) handleUploadFile(msg *config.Message, chatid int64) string {
var c tgbotapi.Chattable
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
file := tgbotapi.FileBytes{
Name: fi.Name,
Bytes: *fi.Data,
}
switch filepath.Ext(fi.Name) {
case ".jpg", ".jpe", ".png":
pc := tgbotapi.NewPhoto(chatid, file)
pc.Caption, pc.ParseMode = TGGetParseMode(b, msg.Username, fi.Comment)
c = pc
case ".mp4", ".m4v":
vc := tgbotapi.NewVideo(chatid, file)
vc.Caption, vc.ParseMode = TGGetParseMode(b, msg.Username, fi.Comment)
c = vc
case ".mp3", ".oga":
ac := tgbotapi.NewAudio(chatid, file)
ac.Caption, ac.ParseMode = TGGetParseMode(b, msg.Username, fi.Comment)
c = ac
case ".ogg":
voc := tgbotapi.NewVoice(chatid, file)
voc.Caption, voc.ParseMode = TGGetParseMode(b, msg.Username, fi.Comment)
c = voc
default:
dc := tgbotapi.NewDocument(chatid, file)
dc.Caption, dc.ParseMode = TGGetParseMode(b, msg.Username, fi.Comment)
c = dc
}
_, err := b.c.Send(c)
if err != nil {
b.Log.Errorf("file upload failed: %#v", err)
}
}
return ""
}
func (b *Btelegram) handleQuote(message, quoteNick, quoteMessage string) string {
format := b.GetString("quoteformat")
if format == "" {
format = "{MESSAGE} (re @{QUOTENICK}: {QUOTEMESSAGE})"
}
quoteMessagelength := len([]rune(quoteMessage))
if b.GetInt("QuoteLengthLimit") != 0 && quoteMessagelength >= b.GetInt("QuoteLengthLimit") {
runes := []rune(quoteMessage)
quoteMessage = string(runes[0:b.GetInt("QuoteLengthLimit")])
if quoteMessagelength > b.GetInt("QuoteLengthLimit") {
quoteMessage += "..."
}
}
format = strings.Replace(format, "{MESSAGE}", message, -1)
format = strings.Replace(format, "{QUOTENICK}", quoteNick, -1)
format = strings.Replace(format, "{QUOTEMESSAGE}", quoteMessage, -1)
return format
}
// handleEntities handles messageEntities
func (b *Btelegram) handleEntities(rmsg *config.Message, message *tgbotapi.Message) {
if message.Entities == nil {
return
}
indexMovedBy := 0
// for now only do URL replacements
for _, e := range message.Entities {
if e.Type == "text_link" {
url, err := e.ParseURL()
if err != nil {
b.Log.Errorf("entity text_link url parse failed: %s", err)
continue
}
utfEncodedString := utf16.Encode([]rune(rmsg.Text))
if e.Offset+e.Length > len(utfEncodedString) {
b.Log.Errorf("entity length is too long %d > %d", e.Offset+e.Length, len(utfEncodedString))
continue
}
link := utf16.Decode(utfEncodedString[e.Offset : e.Offset+e.Length])
rmsg.Text = strings.Replace(rmsg.Text, string(link), url.String(), 1)
}
if e.Type == "code" {
offset := e.Offset + indexMovedBy
rmsg.Text = rmsg.Text[:offset] + "`" + rmsg.Text[offset:offset+e.Length] + "`" + rmsg.Text[offset+e.Length:]
indexMovedBy += 2
}
if e.Type == "pre" {
offset := e.Offset + indexMovedBy
rmsg.Text = rmsg.Text[:offset] + "```\n" + rmsg.Text[offset:offset+e.Length] + "\n```" + rmsg.Text[offset+e.Length:]
indexMovedBy += 8
}
}
}

View File

@ -1,65 +0,0 @@
package btelegram
import (
"bytes"
"html"
"github.com/russross/blackfriday"
)
type customHTML struct {
blackfriday.Renderer
}
func (options *customHTML) Paragraph(out *bytes.Buffer, text func() bool) {
marker := out.Len()
if !text() {
out.Truncate(marker)
return
}
out.WriteString("\n")
}
func (options *customHTML) BlockCode(out *bytes.Buffer, text []byte, lang string) {
out.WriteString("<pre>")
out.WriteString(html.EscapeString(string(text)))
out.WriteString("</pre>\n")
}
func (options *customHTML) Header(out *bytes.Buffer, text func() bool, level int, id string) {
options.Paragraph(out, text)
}
func (options *customHTML) HRule(out *bytes.Buffer) {
out.WriteByte('\n') //nolint:errcheck
}
func (options *customHTML) BlockQuote(out *bytes.Buffer, text []byte) {
out.WriteString("> ")
out.Write(text)
out.WriteByte('\n')
}
func (options *customHTML) List(out *bytes.Buffer, text func() bool, flags int) {
options.Paragraph(out, text)
}
func (options *customHTML) ListItem(out *bytes.Buffer, text []byte, flags int) {
out.WriteString("- ")
out.Write(text)
out.WriteByte('\n')
}
func makeHTML(input string) string {
return string(blackfriday.Markdown([]byte(input),
&customHTML{blackfriday.HtmlRenderer(blackfriday.HTML_USE_XHTML|blackfriday.HTML_SKIP_IMAGES, "", "")},
blackfriday.EXTENSION_NO_INTRA_EMPHASIS|
blackfriday.EXTENSION_FENCED_CODE|
blackfriday.EXTENSION_AUTOLINK|
blackfriday.EXTENSION_SPACE_HEADERS|
blackfriday.EXTENSION_HEADER_IDS|
blackfriday.EXTENSION_BACKSLASH_LINE_BREAK|
blackfriday.EXTENSION_DEFINITION_LISTS))
}

View File

@ -1,170 +0,0 @@
package btelegram
import (
"html"
"log"
"strconv"
"strings"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
const (
unknownUser = "unknown"
HTMLFormat = "HTML"
HTMLNick = "htmlnick"
MarkdownV2 = "MarkdownV2"
)
type Btelegram struct {
c *tgbotapi.BotAPI
*bridge.Config
avatarMap map[string]string // keep cache of userid and avatar sha
}
func New(cfg *bridge.Config) bridge.Bridger {
tgsConvertFormat := cfg.GetString("MediaConvertTgs")
if tgsConvertFormat != "" {
err := helper.CanConvertTgsToX()
if err != nil {
log.Fatalf("Telegram bridge configured to convert .tgs files to '%s', but %s does not appear to work:\n%#v", tgsConvertFormat, helper.LottieBackend(), err)
}
if !helper.SupportsFormat(tgsConvertFormat) {
log.Fatalf("Telegram bridge configured to convert .tgs files to '%s', but %s doesn't support it.", tgsConvertFormat, helper.LottieBackend())
}
}
return &Btelegram{Config: cfg, avatarMap: make(map[string]string)}
}
func (b *Btelegram) Connect() error {
var err error
b.Log.Info("Connecting")
b.c, err = tgbotapi.NewBotAPI(b.GetString("Token"))
if err != nil {
b.Log.Debugf("%#v", err)
return err
}
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates := b.c.GetUpdatesChan(u)
b.Log.Info("Connection succeeded")
go b.handleRecv(updates)
return nil
}
func (b *Btelegram) Disconnect() error {
return nil
}
func (b *Btelegram) JoinChannel(channel config.ChannelInfo) error {
return nil
}
func TGGetParseMode(b *Btelegram, username string, text string) (textout string, parsemode string) {
textout = username + text
if b.GetString("MessageFormat") == HTMLFormat {
b.Log.Debug("Using mode HTML")
parsemode = tgbotapi.ModeHTML
}
if b.GetString("MessageFormat") == "Markdown" {
b.Log.Debug("Using mode markdown")
parsemode = tgbotapi.ModeMarkdown
}
if b.GetString("MessageFormat") == MarkdownV2 {
b.Log.Debug("Using mode MarkdownV2")
parsemode = MarkdownV2
}
if strings.ToLower(b.GetString("MessageFormat")) == HTMLNick {
b.Log.Debug("Using mode HTML - nick only")
textout = username + html.EscapeString(text)
parsemode = tgbotapi.ModeHTML
}
return textout, parsemode
}
func (b *Btelegram) Send(msg config.Message) (string, error) {
b.Log.Debugf("=> Receiving %#v", msg)
// get the chatid
chatid, err := strconv.ParseInt(msg.Channel, 10, 64)
if err != nil {
return "", err
}
// map the file SHA to our user (caches the avatar)
if msg.Event == config.EventAvatarDownload {
return b.cacheAvatar(&msg)
}
if b.GetString("MessageFormat") == HTMLFormat {
msg.Text = makeHTML(msg.Text)
}
// Delete message
if msg.Event == config.EventMsgDelete {
return b.handleDelete(&msg, chatid)
}
// Upload a file if it exists
if msg.Extra != nil {
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
if _, msgErr := b.sendMessage(chatid, rmsg.Username, rmsg.Text); msgErr != nil {
b.Log.Errorf("sendMessage failed: %s", msgErr)
}
}
// check if we have files to upload (from slack, telegram or mattermost)
if len(msg.Extra["file"]) > 0 {
b.handleUploadFile(&msg, chatid)
}
}
// edit the message if we have a msg ID
if msg.ID != "" {
return b.handleEdit(&msg, chatid)
}
// Post normal message
// TODO: recheck it.
// Ignore empty text field needs for prevent double messages from whatsapp to telegram
// when sending media with text caption
if msg.Text != "" {
return b.sendMessage(chatid, msg.Username, msg.Text)
}
return "", nil
}
func (b *Btelegram) getFileDirectURL(id string) string {
res, err := b.c.GetFileDirectURL(id)
if err != nil {
return ""
}
return res
}
func (b *Btelegram) sendMessage(chatid int64, username, text string) (string, error) {
m := tgbotapi.NewMessage(chatid, "")
m.Text, m.ParseMode = TGGetParseMode(b, username, text)
m.DisableWebPagePreview = b.GetBool("DisableWebPagePreview")
res, err := b.c.Send(m)
if err != nil {
return "", err
}
return strconv.Itoa(res.MessageID), nil
}
func (b *Btelegram) cacheAvatar(msg *config.Message) (string, error) {
fi := msg.Extra["file"][0].(config.FileInfo)
/* if we have a sha we have successfully uploaded the file to the media server,
so we can now cache the sha */
if fi.SHA != "" {
b.Log.Debugf("Added %s to %s in avatarMap", fi.SHA, msg.UserID)
b.avatarMap[msg.UserID] = fi.SHA
}
return "", nil
}

View File

@ -1,332 +0,0 @@
package bvk
import (
"bytes"
"context"
"regexp"
"strconv"
"strings"
"time"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/SevereCloud/vksdk/v2/api"
"github.com/SevereCloud/vksdk/v2/events"
longpoll "github.com/SevereCloud/vksdk/v2/longpoll-bot"
"github.com/SevereCloud/vksdk/v2/object"
)
const (
audioMessage = "audio_message"
document = "doc"
photo = "photo"
video = "video"
graffiti = "graffiti"
sticker = "sticker"
wall = "wall"
)
type user struct {
lastname, firstname, avatar string
}
type Bvk struct {
c *api.VK
lp *longpoll.LongPoll
usernamesMap map[int]user // cache of user names and avatar URLs
*bridge.Config
}
func New(cfg *bridge.Config) bridge.Bridger {
return &Bvk{usernamesMap: make(map[int]user), Config: cfg}
}
func (b *Bvk) Connect() error {
b.Log.Info("Connecting")
b.c = api.NewVK(b.GetString("Token"))
var err error
b.lp, err = longpoll.NewLongPollCommunity(b.c)
if err != nil {
b.Log.Debugf("%#v", err)
return err
}
b.lp.MessageNew(func(ctx context.Context, obj events.MessageNewObject) {
b.handleMessage(obj.Message, false)
})
b.Log.Info("Connection succeeded")
go func() {
err := b.lp.Run()
if err != nil {
b.Log.Fatal("Enable longpoll in group management")
}
}()
return nil
}
func (b *Bvk) Disconnect() error {
b.lp.Shutdown()
return nil
}
func (b *Bvk) JoinChannel(channel config.ChannelInfo) error {
return nil
}
func (b *Bvk) Send(msg config.Message) (string, error) {
b.Log.Debugf("=> Receiving %#v", msg)
peerID, err := strconv.Atoi(msg.Channel)
if err != nil {
return "", err
}
params := api.Params{}
text := msg.Username + msg.Text
if msg.Extra != nil {
if len(msg.Extra["file"]) > 0 {
// generate attachments string
attachment, urls := b.uploadFiles(msg.Extra, peerID)
params["attachment"] = attachment
text += urls
}
}
params["message"] = text
if msg.ID == "" {
// New message
params["random_id"] = time.Now().Unix()
params["peer_ids"] = msg.Channel
res, e := b.c.MessagesSendPeerIDs(params)
if e != nil {
return "", err
}
return strconv.Itoa(res[0].ConversationMessageID), nil
}
// Edit message
messageID, err := strconv.ParseInt(msg.ID, 10, 64)
if err != nil {
return "", err
}
params["peer_id"] = peerID
params["conversation_message_id"] = messageID
_, err = b.c.MessagesEdit(params)
if err != nil {
return "", err
}
return msg.ID, nil
}
func (b *Bvk) getUser(id int) user {
u, found := b.usernamesMap[id]
if !found {
b.Log.Debug("Fetching username for ", id)
if id >= 0 {
result, _ := b.c.UsersGet(api.Params{
"user_ids": id,
"fields": "photo_200",
})
resUser := result[0]
u = user{lastname: resUser.LastName, firstname: resUser.FirstName, avatar: resUser.Photo200}
b.usernamesMap[id] = u
} else {
result, _ := b.c.GroupsGetByID(api.Params{
"group_id": id * -1,
})
resGroup := result[0]
u = user{lastname: resGroup.Name, avatar: resGroup.Photo200}
}
}
return u
}
func (b *Bvk) handleMessage(msg object.MessagesMessage, isFwd bool) {
b.Log.Debug("ChatID: ", msg.PeerID)
// fetch user info
u := b.getUser(msg.FromID)
rmsg := config.Message{
Text: msg.Text,
Username: u.firstname + " " + u.lastname,
Avatar: u.avatar,
Channel: strconv.Itoa(msg.PeerID),
Account: b.Account,
UserID: strconv.Itoa(msg.FromID),
ID: strconv.Itoa(msg.ConversationMessageID),
Extra: make(map[string][]interface{}),
}
if msg.ReplyMessage != nil {
ur := b.getUser(msg.ReplyMessage.FromID)
rmsg.Text = "Re: " + ur.firstname + " " + ur.lastname + "\n" + rmsg.Text
}
if isFwd {
rmsg.Username = "Fwd: " + rmsg.Username
}
if len(msg.Attachments) > 0 {
urls, text := b.getFiles(msg.Attachments)
if text != "" {
rmsg.Text += "\n" + text
}
// download
b.downloadFiles(&rmsg, urls)
}
if len(msg.FwdMessages) > 0 {
rmsg.Text += strconv.Itoa(len(msg.FwdMessages)) + " forwarded messages"
}
b.Remote <- rmsg
if len(msg.FwdMessages) > 0 {
// recursive processing of forwarded messages
for _, m := range msg.FwdMessages {
m.PeerID = msg.PeerID
b.handleMessage(m, true)
}
}
}
func (b *Bvk) uploadFiles(extra map[string][]interface{}, peerID int) (string, string) {
var attachments []string
text := ""
for _, f := range extra["file"] {
fi := f.(config.FileInfo)
if fi.Comment != "" {
text += fi.Comment + "\n"
}
a, err := b.uploadFile(fi, peerID)
if err != nil {
b.Log.Error("File upload error ", fi.Name)
}
attachments = append(attachments, a)
}
return strings.Join(attachments, ","), text
}
func (b *Bvk) uploadFile(file config.FileInfo, peerID int) (string, error) {
r := bytes.NewReader(*file.Data)
photoRE := regexp.MustCompile(".(jpg|jpe|png)$")
if photoRE.MatchString(file.Name) {
p, err := b.c.UploadMessagesPhoto(peerID, r)
if err != nil {
return "", err
}
return photo + strconv.Itoa(p[0].OwnerID) + "_" + strconv.Itoa(p[0].ID), nil
}
var doctype string
if strings.Contains(file.Name, ".ogg") {
doctype = audioMessage
} else {
doctype = document
}
doc, err := b.c.UploadMessagesDoc(peerID, doctype, file.Name, "", r)
if err != nil {
return "", err
}
switch doc.Type {
case audioMessage:
return document + strconv.Itoa(doc.AudioMessage.OwnerID) + "_" + strconv.Itoa(doc.AudioMessage.ID), nil
case document:
return document + strconv.Itoa(doc.Doc.OwnerID) + "_" + strconv.Itoa(doc.Doc.ID), nil
}
return "", nil
}
func (b *Bvk) getFiles(attachments []object.MessagesMessageAttachment) ([]string, string) {
var urls []string
var text []string
for _, a := range attachments {
switch a.Type {
case photo:
var resolution float64 = 0
url := a.Photo.Sizes[0].URL
for _, size := range a.Photo.Sizes {
r := size.Height * size.Width
if resolution < r {
resolution = r
url = size.URL
}
}
urls = append(urls, url)
case document:
urls = append(urls, a.Doc.URL)
case graffiti:
urls = append(urls, a.Graffiti.URL)
case audioMessage:
urls = append(urls, a.AudioMessage.DocsDocPreviewAudioMessage.LinkOgg)
case sticker:
var resolution float64 = 0
url := a.Sticker.Images[0].URL
for _, size := range a.Sticker.Images {
r := size.Height * size.Width
if resolution < r {
resolution = r
url = size.URL
}
}
urls = append(urls, url+".png")
case video:
text = append(text, "https://vk.com/video"+strconv.Itoa(a.Video.OwnerID)+"_"+strconv.Itoa(a.Video.ID))
case wall:
text = append(text, "https://vk.com/wall"+strconv.Itoa(a.Wall.FromID)+"_"+strconv.Itoa(a.Wall.ID))
default:
text = append(text, "This attachment is not supported ("+a.Type+")")
}
}
return urls, strings.Join(text, "\n")
}
func (b *Bvk) downloadFiles(rmsg *config.Message, urls []string) {
for _, url := range urls {
data, err := helper.DownloadFile(url)
if err == nil {
urlPart := strings.Split(url, "/")
name := strings.Split(urlPart[len(urlPart)-1], "?")[0]
helper.HandleDownloadData(b.Log, rmsg, name, "", url, data, b.General)
}
}
}

View File

@ -1,380 +0,0 @@
package bwhatsapp
import (
"fmt"
"mime"
"strings"
"time"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/Rhymen/go-whatsapp"
"github.com/jpillora/backoff"
)
/*
Implement handling messages coming from WhatsApp
Check:
- https://github.com/Rhymen/go-whatsapp#add-message-handlers
- https://github.com/Rhymen/go-whatsapp/blob/master/handler.go
- https://github.com/tulir/mautrix-whatsapp/tree/master/whatsapp-ext for more advanced command handling
*/
// HandleError received from WhatsApp
func (b *Bwhatsapp) HandleError(err error) {
// ignore received invalid data errors. https://github.com/42wim/matterbridge/issues/843
// ignore tag 174 errors. https://github.com/42wim/matterbridge/issues/1094
if strings.Contains(err.Error(), "error processing data: received invalid data") ||
strings.Contains(err.Error(), "invalid string with tag 174") {
return
}
switch err.(type) {
case *whatsapp.ErrConnectionClosed, *whatsapp.ErrConnectionFailed:
b.reconnect(err)
default:
switch err {
case whatsapp.ErrConnectionTimeout:
b.reconnect(err)
default:
b.Log.Errorf("%v", err)
}
}
}
func (b *Bwhatsapp) reconnect(err error) {
bf := &backoff.Backoff{
Min: time.Second,
Max: 5 * time.Minute,
Jitter: true,
}
for {
d := bf.Duration()
b.Log.Errorf("Connection failed, underlying error: %v", err)
b.Log.Infof("Waiting %s...", d)
time.Sleep(d)
b.Log.Info("Reconnecting...")
err := b.conn.Restore()
if err == nil {
bf.Reset()
b.startedAt = uint64(time.Now().Unix())
return
}
}
}
// HandleTextMessage sent from WhatsApp, relay it to the brige
func (b *Bwhatsapp) HandleTextMessage(message whatsapp.TextMessage) {
if message.Info.FromMe {
return
}
// whatsapp sends last messages to show context , cut them
if message.Info.Timestamp < b.startedAt {
return
}
groupJID := message.Info.RemoteJid
senderJID := message.Info.SenderJid
if len(senderJID) == 0 {
if message.Info.Source != nil && message.Info.Source.Participant != nil {
senderJID = *message.Info.Source.Participant
}
}
// translate sender's JID to the nicest username we can get
senderName := b.getSenderName(senderJID)
if senderName == "" {
senderName = "Someone" // don't expose telephone number
}
extText := message.Info.Source.Message.ExtendedTextMessage
if extText != nil && extText.ContextInfo != nil && extText.ContextInfo.MentionedJid != nil {
// handle user mentions
for _, mentionedJID := range extText.ContextInfo.MentionedJid {
numberAndSuffix := strings.SplitN(mentionedJID, "@", 2)
// mentions comes as telephone numbers and we don't want to expose it to other bridges
// replace it with something more meaninful to others
mention := b.getSenderNotify(numberAndSuffix[0] + "@s.whatsapp.net")
if mention == "" {
mention = "someone"
}
message.Text = strings.Replace(message.Text, "@"+numberAndSuffix[0], "@"+mention, 1)
}
}
rmsg := config.Message{
UserID: senderJID,
Username: senderName,
Text: message.Text,
Channel: groupJID,
Account: b.Account,
Protocol: b.Protocol,
Extra: make(map[string][]interface{}),
// ParentID: TODO, // TODO handle thread replies // map from Info.QuotedMessageID string
ID: message.Info.Id,
}
if avatarURL, exists := b.userAvatars[senderJID]; exists {
rmsg.Avatar = avatarURL
}
b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}
// HandleImageMessage sent from WhatsApp, relay it to the brige
func (b *Bwhatsapp) HandleImageMessage(message whatsapp.ImageMessage) {
if message.Info.FromMe || message.Info.Timestamp < b.startedAt {
return
}
senderJID := message.Info.SenderJid
if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil {
senderJID = *message.Info.Source.Participant
}
senderName := b.getSenderName(message.Info.SenderJid)
if senderName == "" {
senderName = "Someone" // don't expose telephone number
}
rmsg := config.Message{
UserID: senderJID,
Username: senderName,
Channel: message.Info.RemoteJid,
Account: b.Account,
Protocol: b.Protocol,
Extra: make(map[string][]interface{}),
ID: message.Info.Id,
}
if avatarURL, exists := b.userAvatars[senderJID]; exists {
rmsg.Avatar = avatarURL
}
fileExt, err := mime.ExtensionsByType(message.Type)
if err != nil {
b.Log.Errorf("Mimetype detection error: %s", err)
return
}
// rename .jfif to .jpg https://github.com/42wim/matterbridge/issues/1292
if fileExt[0] == ".jfif" {
fileExt[0] = ".jpg"
}
// rename .jpe to .jpg https://github.com/42wim/matterbridge/issues/1463
if fileExt[0] == ".jpe" {
fileExt[0] = ".jpg"
}
filename := fmt.Sprintf("%v%v", message.Info.Id, fileExt[0])
b.Log.Debugf("Trying to download %s with type %s", filename, message.Type)
data, err := message.Download()
if err != nil {
b.Log.Errorf("Download image failed: %s", err)
return
}
// Move file to bridge storage
helper.HandleDownloadData(b.Log, &rmsg, filename, message.Caption, "", &data, b.General)
b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}
// HandleVideoMessage downloads video messages
func (b *Bwhatsapp) HandleVideoMessage(message whatsapp.VideoMessage) {
if message.Info.FromMe || message.Info.Timestamp < b.startedAt {
return
}
senderJID := message.Info.SenderJid
if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil {
senderJID = *message.Info.Source.Participant
}
senderName := b.getSenderName(message.Info.SenderJid)
if senderName == "" {
senderName = "Someone" // don't expose telephone number
}
rmsg := config.Message{
UserID: senderJID,
Username: senderName,
Channel: message.Info.RemoteJid,
Account: b.Account,
Protocol: b.Protocol,
Extra: make(map[string][]interface{}),
ID: message.Info.Id,
}
if avatarURL, exists := b.userAvatars[senderJID]; exists {
rmsg.Avatar = avatarURL
}
fileExt, err := mime.ExtensionsByType(message.Type)
if err != nil {
b.Log.Errorf("Mimetype detection error: %s", err)
return
}
if len(fileExt) == 0 {
fileExt = append(fileExt, ".mp4")
}
filename := fmt.Sprintf("%v%v", message.Info.Id, fileExt[0])
b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, message.Length, message.Type)
data, err := message.Download()
if err != nil {
b.Log.Errorf("Download video failed: %s", err)
return
}
// Move file to bridge storage
helper.HandleDownloadData(b.Log, &rmsg, filename, message.Caption, "", &data, b.General)
b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}
// HandleAudioMessage downloads audio messages
func (b *Bwhatsapp) HandleAudioMessage(message whatsapp.AudioMessage) {
if message.Info.FromMe || message.Info.Timestamp < b.startedAt {
return
}
senderJID := message.Info.SenderJid
if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil {
senderJID = *message.Info.Source.Participant
}
senderName := b.getSenderName(message.Info.SenderJid)
if senderName == "" {
senderName = "Someone" // don't expose telephone number
}
rmsg := config.Message{
UserID: senderJID,
Username: senderName,
Channel: message.Info.RemoteJid,
Account: b.Account,
Protocol: b.Protocol,
Extra: make(map[string][]interface{}),
ID: message.Info.Id,
}
if avatarURL, exists := b.userAvatars[senderJID]; exists {
rmsg.Avatar = avatarURL
}
fileExt, err := mime.ExtensionsByType(message.Type)
if err != nil {
b.Log.Errorf("Mimetype detection error: %s", err)
return
}
if len(fileExt) == 0 {
fileExt = append(fileExt, ".ogg")
}
filename := fmt.Sprintf("%v%v", message.Info.Id, fileExt[0])
b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, message.Length, message.Type)
data, err := message.Download()
if err != nil {
b.Log.Errorf("Download audio failed: %s", err)
return
}
// Move file to bridge storage
helper.HandleDownloadData(b.Log, &rmsg, filename, "audio message", "", &data, b.General)
b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}
// HandleDocumentMessage downloads documents
func (b *Bwhatsapp) HandleDocumentMessage(message whatsapp.DocumentMessage) {
if message.Info.FromMe || message.Info.Timestamp < b.startedAt {
return
}
senderJID := message.Info.SenderJid
if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil {
senderJID = *message.Info.Source.Participant
}
senderName := b.getSenderName(message.Info.SenderJid)
if senderName == "" {
senderName = "Someone" // don't expose telephone number
}
rmsg := config.Message{
UserID: senderJID,
Username: senderName,
Channel: message.Info.RemoteJid,
Account: b.Account,
Protocol: b.Protocol,
Extra: make(map[string][]interface{}),
ID: message.Info.Id,
}
if avatarURL, exists := b.userAvatars[senderJID]; exists {
rmsg.Avatar = avatarURL
}
fileExt, err := mime.ExtensionsByType(message.Type)
if err != nil {
b.Log.Errorf("Mimetype detection error: %s", err)
return
}
filename := fmt.Sprintf("%v", message.FileName)
b.Log.Debugf("Trying to download %s with extension %s and type %s", filename, fileExt, message.Type)
data, err := message.Download()
if err != nil {
b.Log.Errorf("Download document message failed: %s", err)
return
}
// Move file to bridge storage
helper.HandleDownloadData(b.Log, &rmsg, filename, "document", "", &data, b.General)
b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}

View File

@ -1,163 +0,0 @@
package bwhatsapp
import (
"encoding/gob"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
qrcodeTerminal "github.com/Baozisoftware/qrcode-terminal-go"
"github.com/Rhymen/go-whatsapp"
)
type ProfilePicInfo struct {
URL string `json:"eurl"`
Tag string `json:"tag"`
Status int16 `json:"status"`
}
func qrFromTerminal(invert bool) chan string {
qr := make(chan string)
go func() {
terminal := qrcodeTerminal.New()
if invert {
terminal = qrcodeTerminal.New2(qrcodeTerminal.ConsoleColors.BrightWhite, qrcodeTerminal.ConsoleColors.BrightBlack, qrcodeTerminal.QRCodeRecoveryLevels.Medium)
}
terminal.Get(<-qr).Print()
}()
return qr
}
func (b *Bwhatsapp) readSession() (whatsapp.Session, error) {
session := whatsapp.Session{}
sessionFile := b.Config.GetString(sessionFile)
if sessionFile == "" {
return session, errors.New("if you won't set SessionFile then you will need to scan QR code on every restart")
}
file, err := os.Open(sessionFile)
if err != nil {
return session, err
}
defer file.Close()
decoder := gob.NewDecoder(file)
return session, decoder.Decode(&session)
}
func (b *Bwhatsapp) writeSession(session whatsapp.Session) error {
sessionFile := b.Config.GetString(sessionFile)
if sessionFile == "" {
// we already sent a warning while starting the bridge, so let's be quiet here
return nil
}
file, err := os.Create(sessionFile)
if err != nil {
return err
}
defer file.Close()
encoder := gob.NewEncoder(file)
return encoder.Encode(session)
}
func (b *Bwhatsapp) restoreSession() (*whatsapp.Session, error) {
session, err := b.readSession()
if err != nil {
b.Log.Warn(err.Error())
}
b.Log.Debugln("Restoring WhatsApp session..")
session, err = b.conn.RestoreWithSession(session)
if err != nil {
// restore session connection timed out (I couldn't get over it without logging in again)
return nil, errors.New("failed to restore session: " + err.Error())
}
b.Log.Debugln("Session restored successfully!")
return &session, nil
}
func (b *Bwhatsapp) getSenderName(senderJid string) string {
if sender, exists := b.users[senderJid]; exists {
if sender.Name != "" {
return sender.Name
}
// if user is not in phone contacts
// it is the most obvious scenario unless you sync your phone contacts with some remote updated source
// users can change it in their WhatsApp settings -> profile -> click on Avatar
if sender.Notify != "" {
return sender.Notify
}
if sender.Short != "" {
return sender.Short
}
}
// try to reload this contact
_, err := b.conn.Contacts()
if err != nil {
b.Log.Errorf("error on update of contacts: %v", err)
}
if contact, exists := b.conn.Store.Contacts[senderJid]; exists {
// Add it to the user map
b.users[senderJid] = contact
if contact.Name != "" {
return contact.Name
}
// if user is not in phone contacts
// same as above
return contact.Notify
}
return ""
}
func (b *Bwhatsapp) getSenderNotify(senderJid string) string {
if sender, exists := b.users[senderJid]; exists {
return sender.Notify
}
return ""
}
func (b *Bwhatsapp) GetProfilePicThumb(jid string) (*ProfilePicInfo, error) {
data, err := b.conn.GetProfilePicThumb(jid)
if err != nil {
return nil, fmt.Errorf("failed to get avatar: %v", err)
}
content := <-data
info := &ProfilePicInfo{}
err = json.Unmarshal([]byte(content), info)
if err != nil {
return info, fmt.Errorf("failed to unmarshal avatar info: %v", err)
}
return info, nil
}
func isGroupJid(identifier string) bool {
return strings.HasSuffix(identifier, "@g.us") ||
strings.HasSuffix(identifier, "@temp") ||
strings.HasSuffix(identifier, "@broadcast")
}

View File

@ -1,332 +0,0 @@
package bwhatsapp
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"mime"
"os"
"path/filepath"
"strings"
"time"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/Rhymen/go-whatsapp"
)
const (
// Account config parameters
cfgNumber = "Number"
qrOnWhiteTerminal = "QrOnWhiteTerminal"
sessionFile = "SessionFile"
)
// Bwhatsapp Bridge structure keeping all the information needed for relying
type Bwhatsapp struct {
*bridge.Config
session *whatsapp.Session
conn *whatsapp.Conn
startedAt uint64
users map[string]whatsapp.Contact
userAvatars map[string]string
}
// New Create a new WhatsApp bridge. This will be called for each [whatsapp.<server>] entry you have in the config file
func New(cfg *bridge.Config) bridge.Bridger {
number := cfg.GetString(cfgNumber)
if number == "" {
cfg.Log.Fatalf("Missing configuration for WhatsApp bridge: Number")
}
b := &Bwhatsapp{
Config: cfg,
users: make(map[string]whatsapp.Contact),
userAvatars: make(map[string]string),
}
return b
}
// Connect to WhatsApp. Required implementation of the Bridger interface
func (b *Bwhatsapp) Connect() error {
number := b.GetString(cfgNumber)
if number == "" {
return errors.New("whatsapp's telephone number need to be configured")
}
b.Log.Debugln("Connecting to WhatsApp..")
conn, err := whatsapp.NewConn(20 * time.Second)
if err != nil {
return errors.New("failed to connect to WhatsApp: " + err.Error())
}
b.conn = conn
b.conn.AddHandler(b)
b.Log.Debugln("WhatsApp connection successful")
// load existing session in order to keep it between restarts
b.session, err = b.restoreSession()
if err != nil {
b.Log.Warn(err.Error())
}
// login to a new session
if b.session == nil {
if err = b.Login(); err != nil {
return err
}
}
b.startedAt = uint64(time.Now().Unix())
_, err = b.conn.Contacts()
if err != nil {
return fmt.Errorf("error on update of contacts: %v", err)
}
// see https://github.com/Rhymen/go-whatsapp/issues/137#issuecomment-480316013
for len(b.conn.Store.Contacts) == 0 {
b.conn.Contacts() // nolint:errcheck
<-time.After(1 * time.Second)
}
// map all the users
for id, contact := range b.conn.Store.Contacts {
if !isGroupJid(id) && id != "status@broadcast" {
// it is user
b.users[id] = contact
}
}
// get user avatar asynchronously
go func() {
b.Log.Debug("Getting user avatars..")
for jid := range b.users {
info, err := b.GetProfilePicThumb(jid)
if err != nil {
b.Log.Warnf("Could not get profile photo of %s: %v", jid, err)
} else {
b.Lock()
b.userAvatars[jid] = info.URL
b.Unlock()
}
}
b.Log.Debug("Finished getting avatars..")
}()
return nil
}
// Login to WhatsApp creating a new session. This will require to scan a QR code on your mobile device
func (b *Bwhatsapp) Login() error {
b.Log.Debugln("Logging in..")
invert := b.GetBool(qrOnWhiteTerminal) // false is the default
qrChan := qrFromTerminal(invert)
session, err := b.conn.Login(qrChan)
if err != nil {
b.Log.Warnln("Failed to log in:", err)
return err
}
b.session = &session
b.Log.Infof("Logged into session: %#v", session)
b.Log.Infof("Connection: %#v", b.conn)
err = b.writeSession(session)
if err != nil {
fmt.Fprintf(os.Stderr, "error saving session: %v\n", err)
}
return nil
}
// Disconnect is called while reconnecting to the bridge
// Required implementation of the Bridger interface
func (b *Bwhatsapp) Disconnect() error {
// We could Logout, but that would close the session completely and would require a new QR code scan
// https://github.com/Rhymen/go-whatsapp/blob/c31092027237441cffba1b9cb148eadf7c83c3d2/session.go#L377-L381
return nil
}
// JoinChannel Join a WhatsApp group specified in gateway config as channel='number-id@g.us' or channel='Channel name'
// Required implementation of the Bridger interface
// https://github.com/42wim/matterbridge/blob/2cfd880cdb0df29771bf8f31df8d990ab897889d/bridge/bridge.go#L11-L16
func (b *Bwhatsapp) JoinChannel(channel config.ChannelInfo) error {
byJid := isGroupJid(channel.Name)
// see https://github.com/Rhymen/go-whatsapp/issues/137#issuecomment-480316013
for len(b.conn.Store.Contacts) == 0 {
b.conn.Contacts() // nolint:errcheck
<-time.After(1 * time.Second)
}
// verify if we are member of the given group
if byJid {
// channel.Name specifies static group jID, not the name
if _, exists := b.conn.Store.Contacts[channel.Name]; !exists {
return fmt.Errorf("account doesn't belong to group with jid %s", channel.Name)
}
return nil
}
// channel.Name specifies group name that might change, warn about it
var jids []string
for id, contact := range b.conn.Store.Contacts {
if isGroupJid(id) && contact.Name == channel.Name {
jids = append(jids, id)
}
}
switch len(jids) {
case 0:
// didn't match any group - print out possibilites
for id, contact := range b.conn.Store.Contacts {
if isGroupJid(id) {
b.Log.Infof("%s %s", contact.Jid, contact.Name)
}
}
return fmt.Errorf("please specify group's JID from the list above instead of the name '%s'", channel.Name)
case 1:
return fmt.Errorf("group name might change. Please configure gateway with channel=\"%v\" instead of channel=\"%v\"", jids[0], channel.Name)
default:
return fmt.Errorf("there is more than one group with name '%s'. Please specify one of JIDs as channel name: %v", channel.Name, jids)
}
}
// Post a document message from the bridge to WhatsApp
func (b *Bwhatsapp) PostDocumentMessage(msg config.Message, filetype string) (string, error) {
fi := msg.Extra["file"][0].(config.FileInfo)
// Post document message
message := whatsapp.DocumentMessage{
Info: whatsapp.MessageInfo{
RemoteJid: msg.Channel,
},
Title: fi.Name,
FileName: fi.Name,
Type: filetype,
Content: bytes.NewReader(*fi.Data),
}
b.Log.Debugf("=> Sending %#v", msg)
// create message ID
// TODO follow and act if https://github.com/Rhymen/go-whatsapp/issues/101 implemented
idBytes := make([]byte, 10)
if _, err := rand.Read(idBytes); err != nil {
b.Log.Warn(err.Error())
}
message.Info.Id = strings.ToUpper(hex.EncodeToString(idBytes))
_, err := b.conn.Send(message)
return message.Info.Id, err
}
// Post an image message from the bridge to WhatsApp
// Handle, for sure image/jpeg, image/png and image/gif MIME types
func (b *Bwhatsapp) PostImageMessage(msg config.Message, filetype string) (string, error) {
fi := msg.Extra["file"][0].(config.FileInfo)
// Post image message
message := whatsapp.ImageMessage{
Info: whatsapp.MessageInfo{
RemoteJid: msg.Channel,
},
Type: filetype,
Caption: msg.Username + fi.Comment,
Content: bytes.NewReader(*fi.Data),
}
b.Log.Debugf("=> Sending %#v", msg)
// create message ID
// TODO follow and act if https://github.com/Rhymen/go-whatsapp/issues/101 implemented
idBytes := make([]byte, 10)
if _, err := rand.Read(idBytes); err != nil {
b.Log.Warn(err.Error())
}
message.Info.Id = strings.ToUpper(hex.EncodeToString(idBytes))
_, err := b.conn.Send(message)
return message.Info.Id, err
}
// Send a message from the bridge to WhatsApp
// Required implementation of the Bridger interface
// https://github.com/42wim/matterbridge/blob/2cfd880cdb0df29771bf8f31df8d990ab897889d/bridge/bridge.go#L11-L16
func (b *Bwhatsapp) Send(msg config.Message) (string, error) {
b.Log.Debugf("=> Receiving %#v", msg)
// Delete message
if msg.Event == config.EventMsgDelete {
if msg.ID == "" {
// No message ID in case action is executed on a message sent before the bridge was started
// and then the bridge cache doesn't have this message ID mapped
return "", nil
}
_, err := b.conn.RevokeMessage(msg.Channel, msg.ID, true)
return "", err
}
// Edit message
if msg.ID != "" {
b.Log.Debugf("updating message with id %s", msg.ID)
msg.Text += " (edited)"
}
// Handle Upload a file
if msg.Extra["file"] != nil {
fi := msg.Extra["file"][0].(config.FileInfo)
filetype := mime.TypeByExtension(filepath.Ext(fi.Name))
b.Log.Debugf("Extra file is %#v", filetype)
// TODO: add different types
// TODO: add webp conversion
switch filetype {
case "image/jpeg", "image/png", "image/gif":
return b.PostImageMessage(msg, filetype)
default:
return b.PostDocumentMessage(msg, filetype)
}
}
// Post text message
message := whatsapp.TextMessage{
Info: whatsapp.MessageInfo{
RemoteJid: msg.Channel, // which equals to group id
},
Text: msg.Username + msg.Text,
}
b.Log.Debugf("=> Sending %#v", msg)
return b.conn.Send(message)
}
// TODO do we want that? to allow login with QR code from a bridged channel? https://github.com/tulir/mautrix-whatsapp/blob/513eb18e2d59bada0dd515ee1abaaf38a3bfe3d5/commands.go#L76
//func (b *Bwhatsapp) Command(cmd string) string {
// return ""
//}

View File

@ -1,34 +0,0 @@
package bxmpp
import (
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/matterbridge/go-xmpp"
)
// handleDownloadAvatar downloads the avatar of userid from channel
// sends a EVENT_AVATAR_DOWNLOAD message to the gateway if successful.
// logs an error message if it fails
func (b *Bxmpp) handleDownloadAvatar(avatar xmpp.AvatarData) {
rmsg := config.Message{
Username: "system",
Text: "avatar",
Channel: b.parseChannel(avatar.From),
Account: b.Account,
UserID: avatar.From,
Event: config.EventAvatarDownload,
Extra: make(map[string][]interface{}),
}
if _, ok := b.avatarMap[avatar.From]; !ok {
b.Log.Debugf("Avatar.From: %s", avatar.From)
err := helper.HandleDownloadSize(b.Log, &rmsg, avatar.From+".png", int64(len(avatar.Data)), b.General)
if err != nil {
b.Log.Error(err)
return
}
helper.HandleDownloadData(b.Log, &rmsg, avatar.From+".png", rmsg.Text, "", &avatar.Data, b.General)
b.Log.Debugf("Avatar download complete")
b.Remote <- rmsg
}
}

View File

@ -1,30 +0,0 @@
package bxmpp
import (
"regexp"
"github.com/42wim/matterbridge/bridge/config"
)
var pathRegex = regexp.MustCompile("[^a-zA-Z0-9]+")
// GetAvatar constructs a URL for a given user-avatar if it is available in the cache.
func getAvatar(av map[string]string, userid string, general *config.Protocol) string {
if hash, ok := av[userid]; ok {
// NOTE: This does not happen in bridge/helper/helper.go but messes up XMPP
id := pathRegex.ReplaceAllString(userid, "_")
return general.MediaServerDownload + "/" + hash + "/" + id + ".png"
}
return ""
}
func (b *Bxmpp) cacheAvatar(msg *config.Message) string {
fi := msg.Extra["file"][0].(config.FileInfo)
/* if we have a sha we have successfully uploaded the file to the media server,
so we can now cache the sha */
if fi.SHA != "" {
b.Log.Debugf("Added %s to %s in avatarMap", fi.SHA, msg.UserID)
b.avatarMap[msg.UserID] = fi.SHA
}
return ""
}

View File

@ -1,461 +1,139 @@
package bxmpp
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/jpillora/backoff"
"github.com/matterbridge/go-xmpp"
"github.com/rs/xid"
log "github.com/Sirupsen/logrus"
"github.com/mattn/go-xmpp"
"strings"
"time"
)
type Bxmpp struct {
*bridge.Config
startTime time.Time
xc *xmpp.Client
xmppMap map[string]string
connected bool
sync.RWMutex
avatarAvailability map[string]bool
avatarMap map[string]string
xc *xmpp.Client
xmppMap map[string]string
Config *config.Protocol
origin string
protocol string
Remote chan config.Message
}
func New(cfg *bridge.Config) bridge.Bridger {
return &Bxmpp{
Config: cfg,
xmppMap: make(map[string]string),
avatarAvailability: make(map[string]bool),
avatarMap: make(map[string]string),
}
var flog *log.Entry
var protocol = "xmpp"
func init() {
flog = log.WithFields(log.Fields{"module": protocol})
}
func New(cfg config.Protocol, origin string, c chan config.Message) *Bxmpp {
b := &Bxmpp{}
b.xmppMap = make(map[string]string)
b.Config = &cfg
b.protocol = protocol
b.origin = origin
b.Remote = c
return b
}
func (b *Bxmpp) Connect() error {
b.Log.Infof("Connecting %s", b.GetString("Server"))
if err := b.createXMPP(); err != nil {
b.Log.Debugf("%#v", err)
return err
}
b.Log.Info("Connection succeeded")
go b.manageConnection()
return nil
}
func (b *Bxmpp) Disconnect() error {
return nil
}
func (b *Bxmpp) JoinChannel(channel config.ChannelInfo) error {
if channel.Options.Key != "" {
b.Log.Debugf("using key %s for channel %s", channel.Options.Key, channel.Name)
b.xc.JoinProtectedMUC(channel.Name+"@"+b.GetString("Muc"), b.GetString("Nick"), channel.Options.Key, xmpp.NoHistory, 0, nil)
} else {
b.xc.JoinMUCNoHistory(channel.Name+"@"+b.GetString("Muc"), b.GetString("Nick"))
}
return nil
}
func (b *Bxmpp) Send(msg config.Message) (string, error) {
// should be fixed by using a cache instead of dropping
if !b.Connected() {
return "", fmt.Errorf("bridge %s not connected, dropping message %#v to bridge", b.Account, msg)
}
// ignore delete messages
if msg.Event == config.EventMsgDelete {
return "", nil
}
b.Log.Debugf("=> Receiving %#v", msg)
if msg.Event == config.EventAvatarDownload {
return b.cacheAvatar(&msg), nil
}
// Make a action /me of the message, prepend the username with it.
// https://xmpp.org/extensions/xep-0245.html
if msg.Event == config.EventUserAction {
msg.Username = "/me " + msg.Username
}
// Upload a file (in XMPP case send the upload URL because XMPP has no native upload support).
var err error
if msg.Extra != nil {
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
b.Log.Debugf("=> Sending attachement message %#v", rmsg)
if b.GetString("WebhookURL") != "" {
err = b.postSlackCompatibleWebhook(msg)
} else {
_, err = b.xc.Send(xmpp.Chat{
Type: "groupchat",
Remote: rmsg.Channel + "@" + b.GetString("Muc"),
Text: rmsg.Username + rmsg.Text,
})
}
if err != nil {
b.Log.WithError(err).Error("Unable to send message with share URL.")
}
}
if len(msg.Extra["file"]) > 0 {
return "", b.handleUploadFile(&msg)
}
}
if b.GetString("WebhookURL") != "" {
b.Log.Debugf("Sending message using Webhook")
err := b.postSlackCompatibleWebhook(msg)
if err != nil {
b.Log.Errorf("Failed to send message using webhook: %s", err)
return "", err
}
return "", nil
}
// Post normal message.
var msgReplaceID string
msgID := xid.New().String()
if msg.ID != "" {
msgReplaceID = msg.ID
}
b.Log.Debugf("=> Sending message %#v", msg)
if _, err := b.xc.Send(xmpp.Chat{
Type: "groupchat",
Remote: msg.Channel + "@" + b.GetString("Muc"),
Text: msg.Username + msg.Text,
ID: msgID,
ReplaceID: msgReplaceID,
}); err != nil {
return "", err
}
return msgID, nil
}
func (b *Bxmpp) postSlackCompatibleWebhook(msg config.Message) error {
type XMPPWebhook struct {
Username string `json:"username"`
Text string `json:"text"`
}
webhookBody, err := json.Marshal(XMPPWebhook{
Username: msg.Username,
Text: msg.Text,
})
flog.Infof("Connecting %s", b.Config.Server)
b.xc, err = b.createXMPP()
if err != nil {
b.Log.Errorf("Failed to marshal webhook: %s", err)
flog.Debugf("%#v", err)
return err
}
resp, err := http.Post(b.GetString("WebhookURL")+"/"+url.QueryEscape(msg.Channel), "application/json", bytes.NewReader(webhookBody))
if err != nil {
b.Log.Errorf("Failed to POST webhook: %s", err)
return err
}
resp.Body.Close()
flog.Info("Connection succeeded")
go b.handleXmpp()
return nil
}
func (b *Bxmpp) createXMPP() error {
var serverName string
switch {
case !b.GetBool("Anonymous"):
if !strings.Contains(b.GetString("Jid"), "@") {
return fmt.Errorf("the Jid %s doesn't contain an @", b.GetString("Jid"))
}
serverName = strings.Split(b.GetString("Jid"), "@")[1]
case !strings.Contains(b.GetString("Server"), ":"):
serverName = strings.Split(b.GetString("Server"), ":")[0]
default:
serverName = b.GetString("Server")
}
func (b *Bxmpp) FullOrigin() string {
return b.protocol + "." + b.origin
}
tc := &tls.Config{
ServerName: serverName,
InsecureSkipVerify: b.GetBool("SkipTLSVerify"), // nolint: gosec
}
func (b *Bxmpp) JoinChannel(channel string) error {
b.xc.JoinMUCNoHistory(channel+"@"+b.Config.Muc, b.Config.Nick)
return nil
}
xmpp.DebugWriter = b.Log.Writer()
func (b *Bxmpp) Name() string {
return b.protocol + "." + b.origin
}
func (b *Bxmpp) Protocol() string {
return b.protocol
}
func (b *Bxmpp) Origin() string {
return b.origin
}
func (b *Bxmpp) Send(msg config.Message) error {
flog.Debugf("Receiving %#v", msg)
nick := config.GetNick(&msg, b.Config)
b.xc.Send(xmpp.Chat{Type: "groupchat", Remote: msg.Channel + "@" + b.Config.Muc, Text: nick + msg.Text})
return nil
}
func (b *Bxmpp) createXMPP() (*xmpp.Client, error) {
options := xmpp.Options{
Host: b.GetString("Server"),
User: b.GetString("Jid"),
Password: b.GetString("Password"),
NoTLS: true,
StartTLS: !b.GetBool("NoTLS"),
TLSConfig: tc,
Debug: b.GetBool("debug"),
Host: b.Config.Server,
User: b.Config.Jid,
Password: b.Config.Password,
NoTLS: true,
StartTLS: true,
//StartTLS: false,
Debug: true,
Session: true,
Status: "",
StatusMessage: "",
Resource: "",
InsecureAllowUnencryptedAuth: b.GetBool("NoTLS"),
InsecureAllowUnencryptedAuth: false,
//InsecureAllowUnencryptedAuth: true,
}
var err error
b.xc, err = options.NewClient()
return err
return b.xc, err
}
func (b *Bxmpp) manageConnection() {
b.setConnected(true)
initial := true
bf := &backoff.Backoff{
Min: time.Second,
Max: 5 * time.Minute,
Jitter: true,
}
// Main connection loop. Each iteration corresponds to a successful
// connection attempt and the subsequent handling of the connection.
for {
if initial {
initial = false
} else {
b.Remote <- config.Message{
Username: "system",
Text: "rejoin",
Channel: "",
Account: b.Account,
Event: config.EventRejoinChannels,
}
}
if err := b.handleXMPP(); err != nil {
b.Log.WithError(err).Error("Disconnected.")
b.setConnected(false)
}
// Reconnection loop using an exponential back-off strategy. We
// only break out of the loop if we have successfully reconnected.
for {
d := bf.Duration()
b.Log.Infof("Reconnecting in %s.", d)
time.Sleep(d)
b.Log.Infof("Reconnecting now.")
if err := b.createXMPP(); err == nil {
b.setConnected(true)
bf.Reset()
break
}
b.Log.Warn("Failed to reconnect.")
}
}
}
func (b *Bxmpp) xmppKeepAlive() chan bool {
done := make(chan bool)
func (b *Bxmpp) xmppKeepAlive() {
go func() {
ticker := time.NewTicker(90 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
b.Log.Debugf("PING")
if err := b.xc.PingC2S("", ""); err != nil {
b.Log.Debugf("PING failed %#v", err)
}
case <-done:
return
b.xc.Send(xmpp.Chat{})
}
}
}()
return done
}
func (b *Bxmpp) handleXMPP() error {
b.startTime = time.Now()
done := b.xmppKeepAlive()
defer close(done)
func (b *Bxmpp) handleXmpp() error {
for {
m, err := b.xc.Recv()
if err != nil {
// An error together with AvatarData is non-fatal
switch m.(type) {
case xmpp.AvatarData:
continue
default:
return err
}
}
switch v := m.(type) {
case xmpp.Chat:
if v.Type == "groupchat" {
b.Log.Debugf("== Receiving %#v", v)
// Skip invalid messages.
if b.skipMessage(v) {
continue
}
var event string
if strings.Contains(v.Text, "has set the subject to:") {
event = config.EventTopicChange
}
available, sok := b.avatarAvailability[v.Remote]
avatar := ""
if !sok {
b.Log.Debugf("Requesting avatar data")
b.avatarAvailability[v.Remote] = false
b.xc.AvatarRequestData(v.Remote)
} else if available {
avatar = getAvatar(b.avatarMap, v.Remote, b.General)
}
msgID := v.ID
if v.ReplaceID != "" {
msgID = v.ReplaceID
}
rmsg := config.Message{
Username: b.parseNick(v.Remote),
Text: v.Text,
Channel: b.parseChannel(v.Remote),
Account: b.Account,
Avatar: avatar,
UserID: v.Remote,
ID: msgID,
Event: event,
}
// Check if we have an action event.
var ok bool
rmsg.Text, ok = b.replaceAction(rmsg.Text)
if ok {
rmsg.Event = config.EventUserAction
}
b.Log.Debugf("<= Sending message from %s on %s to gateway", rmsg.Username, b.Account)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}
case xmpp.AvatarData:
b.handleDownloadAvatar(v)
b.avatarAvailability[v.From] = true
b.Log.Debugf("Avatar for %s is now available", v.From)
case xmpp.Presence:
// Do nothing.
}
}
}
func (b *Bxmpp) replaceAction(text string) (string, bool) {
if strings.HasPrefix(text, "/me ") {
return strings.Replace(text, "/me ", "", -1), true
}
return text, false
}
// handleUploadFile handles native upload of files
func (b *Bxmpp) handleUploadFile(msg *config.Message) error {
var urlDesc string
for _, file := range msg.Extra["file"] {
fileInfo := file.(config.FileInfo)
if fileInfo.Comment != "" {
msg.Text += fileInfo.Comment + ": "
}
if fileInfo.URL != "" {
msg.Text = fileInfo.URL
if fileInfo.Comment != "" {
msg.Text = fileInfo.Comment + ": " + fileInfo.URL
urlDesc = fileInfo.Comment
}
}
if _, err := b.xc.Send(xmpp.Chat{
Type: "groupchat",
Remote: msg.Channel + "@" + b.GetString("Muc"),
Text: msg.Username + msg.Text,
}); err != nil {
return err
}
if fileInfo.URL != "" {
if _, err := b.xc.SendOOB(xmpp.Chat{
Type: "groupchat",
Remote: msg.Channel + "@" + b.GetString("Muc"),
Ooburl: fileInfo.URL,
Oobdesc: urlDesc,
}); err != nil {
b.Log.WithError(err).Warn("Failed to send share URL.")
switch v := m.(type) {
case xmpp.Chat:
var channel, nick string
if v.Type == "groupchat" {
s := strings.Split(v.Remote, "@")
if len(s) == 2 {
channel = s[0]
}
s = strings.Split(s[1], "/")
if len(s) == 2 {
nick = s[1]
}
if nick != b.Config.Nick {
flog.Debugf("Sending message from %s on %s to gateway", nick, b.FullOrigin())
b.Remote <- config.Message{Username: nick, Text: v.Text, Channel: channel, Origin: b.origin, Protocol: b.protocol, FullOrigin: b.FullOrigin()}
}
}
case xmpp.Presence:
// do nothing
}
}
return nil
}
func (b *Bxmpp) parseNick(remote string) string {
s := strings.Split(remote, "@")
if len(s) > 1 {
s = strings.Split(s[1], "/")
if len(s) == 2 {
return s[1] // nick
}
}
return ""
}
func (b *Bxmpp) parseChannel(remote string) string {
s := strings.Split(remote, "@")
if len(s) >= 2 {
return s[0] // channel
}
return ""
}
// skipMessage skips messages that need to be skipped
func (b *Bxmpp) skipMessage(message xmpp.Chat) bool {
// skip messages from ourselves
if b.parseNick(message.Remote) == b.GetString("Nick") {
return true
}
// skip empty messages
if message.Text == "" {
return true
}
// skip subject messages
if strings.Contains(message.Text, "</subject>") {
return true
}
// do not show subjects on connect #732
if strings.Contains(message.Text, "has set the subject to:") && time.Since(b.startTime) < time.Second*5 {
return true
}
// Ignore messages posted by our webhook
if b.GetString("WebhookURL") != "" && strings.Contains(message.ID, "webhookbot") {
return true
}
// skip delayed messages
return !message.Stamp.IsZero() && time.Since(message.Stamp).Minutes() > 5
}
func (b *Bxmpp) setConnected(state bool) {
b.Lock()
b.connected = state
defer b.Unlock()
}
func (b *Bxmpp) Connected() bool {
b.RLock()
defer b.RUnlock()
return b.connected
}

View File

@ -1,216 +0,0 @@
package bzulip
import (
"encoding/json"
"fmt"
"io/ioutil"
"strconv"
"strings"
"sync"
"time"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/42wim/matterbridge/version"
gzb "github.com/matterbridge/gozulipbot"
)
type Bzulip struct {
q *gzb.Queue
bot *gzb.Bot
streams map[int]string
*bridge.Config
sync.RWMutex
}
func New(cfg *bridge.Config) bridge.Bridger {
return &Bzulip{Config: cfg, streams: make(map[int]string)}
}
func (b *Bzulip) Connect() error {
bot := gzb.Bot{APIKey: b.GetString("token"), APIURL: b.GetString("server") + "/api/v1/", Email: b.GetString("login"), UserAgent: fmt.Sprintf("matterbridge/%s", version.Release)}
bot.Init()
q, err := bot.RegisterAll()
b.q = q
b.bot = &bot
if err != nil {
b.Log.Errorf("Connect() %#v", err)
return err
}
// init stream
b.getChannel(0)
b.Log.Info("Connection succeeded")
go b.handleQueue()
return nil
}
func (b *Bzulip) Disconnect() error {
return nil
}
func (b *Bzulip) JoinChannel(channel config.ChannelInfo) error {
return nil
}
func (b *Bzulip) Send(msg config.Message) (string, error) {
b.Log.Debugf("=> Receiving %#v", msg)
// Delete message
if msg.Event == config.EventMsgDelete {
if msg.ID == "" {
return "", nil
}
_, err := b.bot.UpdateMessage(msg.ID, "")
return "", err
}
// Upload a file if it exists
if msg.Extra != nil {
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
b.sendMessage(rmsg)
}
if len(msg.Extra["file"]) > 0 {
return b.handleUploadFile(&msg)
}
}
// edit the message if we have a msg ID
if msg.ID != "" {
_, err := b.bot.UpdateMessage(msg.ID, msg.Username+msg.Text)
return "", err
}
// Post normal message
return b.sendMessage(msg)
}
func (b *Bzulip) getChannel(id int) string {
if name, ok := b.streams[id]; ok {
return name
}
streams, err := b.bot.GetRawStreams()
if err != nil {
b.Log.Errorf("getChannel: %#v", err)
return ""
}
for _, stream := range streams.Streams {
b.streams[stream.StreamID] = stream.Name
}
if name, ok := b.streams[id]; ok {
return name
}
return ""
}
func (b *Bzulip) handleQueue() error {
for {
messages, err := b.q.GetEvents()
switch err {
case gzb.BackoffError:
time.Sleep(time.Second * 5)
case gzb.NoJSONError:
b.Log.Error("Response wasn't JSON, server down or restarting? sleeping 10 seconds")
time.Sleep(time.Second * 10)
case gzb.BadEventQueueError:
b.Log.Info("got a bad event queue id error, reconnecting")
b.bot.Queues = nil
for {
b.q, err = b.bot.RegisterAll()
if err != nil {
b.Log.Errorf("reconnecting failed: %s. Sleeping 10 seconds", err)
time.Sleep(time.Second * 10)
}
break
}
case gzb.HeartbeatError:
b.Log.Debug("heartbeat received.")
default:
b.Log.Debugf("receiving error: %#v", err)
time.Sleep(time.Second * 10)
}
if err != nil {
continue
}
for _, m := range messages {
b.Log.Debugf("== Receiving %#v", m)
// ignore our own messages
if m.SenderEmail == b.GetString("login") {
continue
}
avatarURL := m.AvatarURL
if !strings.HasPrefix(avatarURL, "http") {
avatarURL = b.GetString("server") + avatarURL
}
rmsg := config.Message{
Username: m.SenderFullName,
Text: m.Content,
Channel: b.getChannel(m.StreamID) + "/topic:" + m.Subject,
Account: b.Account,
UserID: strconv.Itoa(m.SenderID),
Avatar: avatarURL,
}
b.Log.Debugf("<= Sending message from %s on %s to gateway", rmsg.Username, b.Account)
b.Log.Debugf("<= Message is %#v", rmsg)
b.Remote <- rmsg
}
time.Sleep(time.Second * 3)
}
}
func (b *Bzulip) sendMessage(msg config.Message) (string, error) {
topic := ""
if strings.Contains(msg.Channel, "/topic:") {
res := strings.Split(msg.Channel, "/topic:")
topic = res[1]
msg.Channel = res[0]
}
m := gzb.Message{
Stream: msg.Channel,
Topic: topic,
Content: msg.Username + msg.Text,
}
resp, err := b.bot.Message(m)
if err != nil {
return "", err
}
if resp != nil {
defer resp.Body.Close()
res, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
var jr struct {
ID int `json:"id"`
}
err = json.Unmarshal(res, &jr)
if err != nil {
return "", err
}
return strconv.Itoa(jr.ID), nil
}
return "", nil
}
func (b *Bzulip) handleUploadFile(msg *config.Message) (string, error) {
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
if fi.Comment != "" {
msg.Text += fi.Comment + ": "
}
if fi.URL != "" {
msg.Text = fi.URL
if fi.Comment != "" {
msg.Text = fi.Comment + ": " + fi.URL
}
}
_, err := b.sendMessage(*msg)
if err != nil {
return "", err
}
}
return "", nil
}

File diff suppressed because it is too large Load Diff

View File

@ -1,210 +0,0 @@
openapi: 3.0.0
info:
contact: {}
description: A read/write API for the Matterbridge chat bridge.
license:
name: Apache 2.0
url: 'https://github.com/42wim/matterbridge/blob/master/LICENSE'
title: Matterbridge API
version: "0.1.0-oas3"
paths:
/health:
get:
responses:
'200':
description: OK
content:
'*/*':
schema:
type: string
summary: Checks if the server is alive.
/message:
post:
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/config.OutgoingMessageResponse'
summary: Create a message
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/config.OutgoingMessage'
description: Message object to create
required: true
/messages:
get:
responses:
'200':
description: OK
content:
application/json:
schema:
items:
$ref: '#/components/schemas/config.IncomingMessage'
type: array
security:
- ApiKeyAuth: []
summary: List new messages
/stream:
get:
responses:
'200':
description: OK
content:
application/x-json-stream:
schema:
$ref: '#/components/schemas/config.IncomingMessage'
summary: Stream realtime messages
servers:
- url: /api
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
schemas:
config.IncomingMessage:
properties:
avatar:
description: URL to an avatar image
example: >-
https://secure.gravatar.com/avatar/1234567890abcdef1234567890abcdef.jpg
type: string
event:
description: >-
A specific matterbridge event. (see
https://github.com/42wim/matterbridge/blob/master/bridge/config/config.go#L16)
type: string
gateway:
description: Name of the gateway as configured in matterbridge.toml
example: mygateway
type: string
text:
description: Content of the message
example: 'Testing, testing, 1-2-3.'
type: string
username:
description: Human-readable username
example: alice
type: string
account:
description: Unique account name of format "[protocol].[slug]" as defined in matterbridge.toml
example: slack.myteam
type: string
channel:
description: Human-readable channel name of sending bridge
example: test-channel
type: string
id:
description: Unique ID of message on the gateway
example: slack 1541361213.030700
type: string
parent_id:
description: Unique ID of a parent message, if threaded
example: slack 1541361213.030700
type: string
protocol:
description: Chat protocol of the sending bridge
example: slack
type: string
timestamp:
description: Timestamp of the message
example: "1541361213.030700"
type: string
userid:
description: Userid on the sending bridge
example: U4MCXJKNC
type: string
extra:
description: Extra data that doesn't fit in other fields (eg base64 encoded files)
type: object
config.OutgoingMessage:
properties:
avatar:
description: URL to an avatar image
example: >-
https://secure.gravatar.com/avatar/1234567890abcdef1234567890abcdef.jpg
type: string
event:
description: >-
A specific matterbridge event. (see
https://github.com/42wim/matterbridge/blob/master/bridge/config/config.go#L16)
example: ""
type: string
gateway:
description: Name of the gateway as configured in matterbridge.toml
example: mygateway
type: string
text:
description: Content of the message
example: 'Testing, testing, 1-2-3.'
type: string
username:
description: Human-readable username
example: alice
type: string
type: object
required:
- gateway
- text
- username
config.OutgoingMessageResponse:
properties:
avatar:
description: URL to an avatar image
example: >-
https://secure.gravatar.com/avatar/1234567890abcdef1234567890abcdef.jpg
type: string
event:
description: >-
A specific matterbridge event. (see
https://github.com/42wim/matterbridge/blob/master/bridge/config/config.go#L16)
example: ""
type: string
gateway:
description: Name of the gateway as configured in matterbridge.toml
example: mygateway
type: string
text:
description: Content of the message
example: 'Testing, testing, 1-2-3.'
type: string
username:
description: Human-readable username
example: alice
type: string
account:
description: fixed api account
example: api.local
type: string
channel:
description: fixed api channel
example: api
type: string
id:
example: ""
type: string
parent_id:
example: ""
type: string
protocol:
description: fixed api protocol
example: api
type: string
timestamp:
description: Timestamp of the message
example: "1541361213.030700"
type: string
userid:
example: ""
type: string
extra:
example: null
type: object
type: object
security:
- bearerAuth: []

View File

@ -1,2 +0,0 @@
text := import("text")
msgText=text.re_replace("matterbridge",msgText,"matterbridge (https://github.com/42wim/matterbridge)")

View File

@ -1,11 +0,0 @@
[Unit]
Description=matterbridge
After=network.target
[Service]
ExecStart=/usr/bin/matterbridge -conf /etc/matterbridge/bridge.toml
User=matterbridge
Group=matterbridge
[Install]
WantedBy=multi-user.target

View File

@ -1,10 +0,0 @@
text := import("text")
// if we're not sending to a discord bridge,
// then convert custom emoji tags into url's
if (inProtocol == "discord" && outProtocol != "discord") {
rePNG := text.re_compile(`<:.*?:([0-9]+)>`)
msgText=rePNG.replace(msgText,"https://cdn.discordapp.com/emojis/$1.png")
reGIF := text.re_compile(`<a:.*?:([0-9]+)>`)
msgText=reGIF.replace(msgText,"https://cdn.discordapp.com/emojis/$1.gif")
}

View File

@ -1,14 +0,0 @@
// See https://github.com/42wim/matterbridge/issues/881
// Generates a colored nick for each msgUsername, with example to filter specific codes
text := import("text")
fmt := import("fmt")
if outProtocol == "irc" {
// generate a color for a nick, make sure it isn't 0 or 15
colorCode := len(msgUsername)+bytes(msgUsername)[0]%14 + 2
// example if we want to use colorCode 3 when we have calculated colorcode 14
if colorCode == 14 {
colorCode = 3
}
msgUsername=fmt.sprintf("\x03%02d%s\x0F", colorCode, msgUsername)
}

View File

@ -1,7 +0,0 @@
// See https://github.com/42wim/matterbridge/issues/798
// if we're not sending to an irc bridge we strip the IRC colors
if outProtocol != "irc" {
re := text.re_compile(`\x03(?:\d{1,2}(?:,\d{1,2})?)?|[[:cntrl:]]`)
msgText=re.replace(msgText,"")
}

View File

@ -1,16 +0,0 @@
/*
This script will return the nick except with multi-character usernames
containing a zero-width space between the first and second character letter.
Single character usernames will be left untouched.
This is useful to prevent remote users from nickalerting
IRC users of the same name when the remote user speaks.
This result can be used in {TENGO} in RemoteNickFormat.
*/
result = nick
if len(nick) > 1 {
result = string(nick[0]) + "" + nick[1:]
}

View File

@ -1,9 +0,0 @@
/*
This script will return the current time in kitchen format if the protocol (of the remote bridge) isn't irc
See https://github.com/d5/tengo/blob/master/docs/stdlib-times.md
This result can be used in {TENGO} in RemoteNickFormat
*/
times := import("times")
if protocol != "irc" {
result=times.time_format(times.now(),times.format_kitchen)
}

View File

@ -1,10 +0,0 @@
FROM alpine:edge as certs
RUN apk --update add ca-certificates
ARG VERSION=1.22.3
ADD https://github.com/42wim/matterbridge/releases/download/v${VERSION}/matterbridge-${VERSION}-linux-arm64 /bin/matterbridge
RUN chmod +x /bin/matterbridge
FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=certs /bin/matterbridge /bin/matterbridge
ENTRYPOINT ["/bin/matterbridge"]

View File

@ -1,5 +0,0 @@
text := import("text")
if text.re_match("blah",msgText) {
msgText="replaced by this"
msgUsername="fakeuser"
}

View File

@ -1,11 +0,0 @@
// +build !noapi
package bridgemap
import (
"github.com/42wim/matterbridge/bridge/api"
)
func init() {
FullMap["api"] = api.New
}

View File

@ -1,12 +0,0 @@
// +build !nodiscord
package bridgemap
import (
bdiscord "github.com/42wim/matterbridge/bridge/discord"
)
func init() {
FullMap["discord"] = bdiscord.New
UserTypingSupport["discord"] = struct{}{}
}

View File

@ -1,11 +0,0 @@
// +build !nogitter
package bridgemap
import (
bgitter "github.com/42wim/matterbridge/bridge/gitter"
)
func init() {
FullMap["gitter"] = bgitter.New
}

View File

@ -1,12 +0,0 @@
//go:build !noharmony
// +build !noharmony
package bridgemap
import (
bharmony "github.com/42wim/matterbridge/bridge/harmony"
)
func init() {
FullMap["harmony"] = bharmony.New
}

View File

@ -1,11 +0,0 @@
// +build !noirc
package bridgemap
import (
birc "github.com/42wim/matterbridge/bridge/irc"
)
func init() {
FullMap["irc"] = birc.New
}

View File

@ -1,11 +0,0 @@
// +build !nokeybase
package bridgemap
import (
bkeybase "github.com/42wim/matterbridge/bridge/keybase"
)
func init() {
FullMap["keybase"] = bkeybase.New
}

View File

@ -1,11 +0,0 @@
// +build !nomatrix
package bridgemap
import (
bmatrix "github.com/42wim/matterbridge/bridge/matrix"
)
func init() {
FullMap["matrix"] = bmatrix.New
}

View File

@ -1,11 +0,0 @@
// +build !nomattermost
package bridgemap
import (
bmattermost "github.com/42wim/matterbridge/bridge/mattermost"
)
func init() {
FullMap["mattermost"] = bmattermost.New
}

View File

@ -1,11 +0,0 @@
// +build !nomsteams
package bridgemap
import (
bmsteams "github.com/42wim/matterbridge/bridge/msteams"
)
func init() {
FullMap["msteams"] = bmsteams.New
}

View File

@ -1,11 +0,0 @@
// +build !nomumble
package bridgemap
import (
bmumble "github.com/42wim/matterbridge/bridge/mumble"
)
func init() {
FullMap["mumble"] = bmumble.New
}

View File

@ -1,11 +0,0 @@
// +build !nonctalk
package bridgemap
import (
btalk "github.com/42wim/matterbridge/bridge/nctalk"
)
func init() {
FullMap["nctalk"] = btalk.New
}

View File

@ -1,10 +0,0 @@
package bridgemap
import (
"github.com/42wim/matterbridge/bridge"
)
var (
FullMap = map[string]bridge.Factory{}
UserTypingSupport = map[string]struct{}{}
)

View File

@ -1,11 +0,0 @@
// +build !norocketchat
package bridgemap
import (
brocketchat "github.com/42wim/matterbridge/bridge/rocketchat"
)
func init() {
FullMap["rocketchat"] = brocketchat.New
}

View File

@ -1,13 +0,0 @@
// +build !noslack
package bridgemap
import (
bslack "github.com/42wim/matterbridge/bridge/slack"
)
func init() {
FullMap["slack-legacy"] = bslack.NewLegacy
FullMap["slack"] = bslack.New
UserTypingSupport["slack"] = struct{}{}
}

View File

@ -1,11 +0,0 @@
// +build !nosshchat
package bridgemap
import (
bsshchat "github.com/42wim/matterbridge/bridge/sshchat"
)
func init() {
FullMap["sshchat"] = bsshchat.New
}

View File

@ -1,11 +0,0 @@
// +build !nosteam
package bridgemap
import (
bsteam "github.com/42wim/matterbridge/bridge/steam"
)
func init() {
FullMap["steam"] = bsteam.New
}

View File

@ -1,11 +0,0 @@
// +build !notelegram
package bridgemap
import (
btelegram "github.com/42wim/matterbridge/bridge/telegram"
)
func init() {
FullMap["telegram"] = btelegram.New
}

Some files were not shown because too many files have changed in this diff Show More