Adding supplementary required files

This commit is contained in:
Jonathan Ervine 2020-07-24 10:19:56 +08:00
parent 06faadb3a4
commit 4fa33d1de6
16 changed files with 8438 additions and 0 deletions

7582
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

84
package.json Normal file
View File

@ -0,0 +1,84 @@
{
"name": "slack2hangoutschat-webhook",
"version": "0.2.0",
"description": "Slack 2 Google HangoutChat webhook converter",
"repository": {
"type": "git",
"url": "https://github.com/tchiotludo/slack2hangoutschat-webhook.git"
},
"keywords": [
"hangouts-chat",
"google-chat",
"slack",
"webhook"
],
"author": "tchiotludo",
"license": "MIT",
"scripts": {
"start": "node dist/server.js",
"build": "npm run build:ts && npm run lint:ts",
"build:ts": "tsc",
"watch": "concurrently -k -p \"[{name}]\" -n \"TypeScript,Node\" -c \"cyan.bold,green.bold\" \"npm run watch:ts\" \"npm run watch:node\"",
"watch:test": "npm run test -- --watchAll",
"watch:node": "nodemon dist/server.js",
"watch:ts": "tsc -w",
"test": "jest --forceExit --coverage --verbose",
"lint": "npm run lint:ts && ",
"lint:ts": "tslint -c tslint.json -p tsconfig.json"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"engines": {
"node": ">= 6.0.0"
},
"dependencies": {
"@slack/client": "^5.0.2",
"body-parser": "^1.19.0",
"compression": "^1.7.4",
"errorhandler": "^1.5.1",
"express": "^4.17.1",
"express-winston": "^4.0.1",
"hangouts-chat-webhook": "^0.1.1",
"superagent": "^5.1.2",
"winston": "^3.2.1"
},
"devDependencies": {
"@types/compression": "^1.0.1",
"@types/errorhandler": "^0.0.32",
"@types/express": "^4.17.2",
"@types/express-winston": "^4.0.0",
"@types/jest": "^24.0.23",
"@types/node": "^12.12.16",
"@types/superagent": "^4.1.4",
"@types/supertest": "^2.0.8",
"concurrently": "^5.0.1",
"coveralls": "^3.0.9",
"jest": "^24.9.0",
"jest-extended": "^0.11.2",
"jest-junit": "^10.0.0",
"nodemon": "^2.0.1",
"supertest": "^4.0.2",
"ts-jest": "^24.2.0",
"ts-node": "^8.5.4",
"tslint": "^5.20.1",
"typescript": "^3.7.3"
},
"jest": {
"globals": {
"ts-jest": {
"tsConfigFile": "tsconfig.json"
}
},
"moduleFileExtensions": [
"ts",
"js"
],
"transform": {
"^.+\\.(ts|tsx)$": "./node_modules/ts-jest/preprocessor.js"
},
"testMatch": [
"**/test/**/*.test.(ts|js)"
],
"testEnvironment": "node"
}
}

BIN
public/chat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

BIN
public/favicon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

175
public/index.html Normal file
View File

@ -0,0 +1,175 @@
<!doctype html>
<html lang="en">
<head>
<title>Slack 2 Hangouts Chat Webhook</title>
<meta name="description" content="">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" href="favicon.jpg">
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Montserrat:700" rel="stylesheet">
<link href="style.css" rel="stylesheet">
</head>
<body class="text-center">
<a href="https://github.com/you"><img style="position: absolute; top: 0; right: 0; border: 0;" src="https://s3.amazonaws.com/github/ribbons/forkme_right_red_aa0000.png" alt="Fork me on GitHub"></a>
<div class="cover-container d-flex h-100 p-3 mx-auto flex-column">
<main role="main" class="inner cover">
<article class="mb-5">
<img src="slack.png" alt="Slack"/>
<span class="text-warning"></span>
<img src="chat.png" alt="Hangouts chat"/>
</article>
<h1 class="cover-heading text-warning">Slack 2 Hangouts Chat Webhook</h1>
<p class="lead mb-5">
Don't wait that applications <code>add notification</code> to Hangouts Chat. <br />
<code>Simply paste</code> your Hangouts Chat webhook url below and get a webhook url
<code>compatible</code> with Slack webhook.
</p>
<div class="form-group">
<label for="url">Enter your Hangouts Chat webhook url</label>
<input type="url" class="form-control" id="url"
placeholder="https://chat.googleapis.com/v1/spaces/{{space}}/messages?key={{key}&token={{token}}}"
required>
<div class="invalid-feedback">
Please provide a Hangouts Chat webhook url.
</div>
</div>
<div class="form-group">
<label for="converted">Webhook url to use in your application</label>
<input type="url" class="form-control" id="converted" readonly>
</div>
<input type="button" class="mt-2 btn btn-lg btn-info convert" value="Give me my url !" />
<form class="d-none">
<div class="alert alert-dismissible mt-5 d-none text-left" role="alert">
<pre>
</pre>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="form-group mt-5">
<label for="slack">Slack webhook</label>
<textarea class="form-control" id="slack" rows="10">{
"attachments": [
{
"fallback": "Required plain-text summary of the attachment.",
"color": "#36a64f",
"pretext": "Optional text that appears above the attachment block",
"author_name": "Bobby Tables",
"author_link": "https://gsuite.google.com/products/chat/",
"author_icon": "https://www.gstatic.com/images/branding/product/2x/chat_64dp.png",
"title": "Slack API Documentation",
"title_link": "https://api.slack.com/",
"text": "Optional text that appears within the attachment",
"fields": [
{
"title": "Priority",
"value": "High",
"short": false
}
],
"image_url": "https://assets.brandfolder.com/oox8px-b08c7c-5m1qjd/original/full-color-mark%202x.png",
"thumb_url": "https://assets.brandfolder.com/oox90q-9q2cew-bw1vdr/view.png",
"footer": "Slack API",
"footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png",
"ts": 123456789
}
]
}</textarea>
</div>
<input type="submit" class="mt-2 btn btn-lg btn-info" value="Test webhook !" />
</form>
</main>
<footer class="mastfoot mt-5">
<div class="inner">
<p>
<a href="https://www.npmjs.com/package/slack2hangoutschat-webhook">
<img src="https://img.shields.io/npm/dt/slack2hangoutschat-webhook.svg?style=social" alt="Npm downloads">
</a>
<a href="https://github.com/tchiotludo/slack2hangoutschat-webhook">
<img src="https://img.shields.io/github/stars/tchiotludo/slack2hangoutschat-webhook.svg?style=social&label=Stars"
alt="Github Stars"/>
</a>
</p>
</div>
</footer>
</div>
<script
src="https://code.jquery.com/jquery-3.3.1.min.js"
integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
crossorigin="anonymous"></script>
<script type="application/javascript">
let form = $('form');
let converted = $('#converted');
let alert = $('.alert');
alert.on('ready', () => {
alert.alert();
});
$('input.convert').on('click', function() {
let url = $('#url');
let invalid = $('.invalid-feedback');
invalid.css('display', 'none');
form.addClass('d-none');
let match = url
.val()
.match(/https:\/\/chat\.googleapis\.com\/v1\/spaces\/([^/]+)\/messages\?(.*)/);
if (match && match[1] && match[2]) {
converted.val(window.location.origin + '/' + match[1] + '?' + match[2]);
form.removeClass('d-none')
} else {
converted.val('');
invalid.css('display', 'block');
}
});
form.on('submit', function(e) {
e.preventDefault();
$.ajax({
url: converted.val(),
type: "POST",
dataType: "json",
data: $('textarea').val(),
contentType: "application/json",
beforeSend: () => {
alert
.addClass('d-none')
.removeClass('alert-success')
.removeClass('alert-danger')
}
})
.done((data) => {
alert
.removeClass('d-none')
.addClass('alert-success')
.find('pre')
.text(JSON.stringify(data, null, 2))
})
.fail((xhr) => {
alert
.removeClass('d-none')
.addClass('alert-danger')
.find('pre')
.text(xhr.responseText)
})
});
</script>
</body>
</html>

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow:

BIN
public/slack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

87
public/style.css Normal file
View File

@ -0,0 +1,87 @@
a,
a:focus,
a:hover {
color: #fff;
}
.btn-secondary,
.btn-secondary:hover,
.btn-secondary:focus {
color: #333;
text-shadow: none;
background-color: #fff;
border: .05rem solid #fff;
}
html {
height: 100%;
}
html,
body {
min-height: 100%;
background-color: #333;
min-width: 320px;
}
body {
display: -ms-flexbox;
display: -webkit-box;
display: flex;
-ms-flex-pack: center;
-webkit-box-pack: center;
justify-content: center;
color: #fff;
text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5);
box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5);
}
.cover-container {
max-width: 42em;
}
.cover {
padding: 0 1.5rem;
}
.cover article {
font-family: Montserrat, serif;
font-size: 20vw;
font-weight: bold;
text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5);
border: .05rem solid #000;
box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5);
line-height: 5rem;
padding: 3vw;
white-space: nowrap;
}
textarea.form-control {
font-family: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
font-size: 0.75rem;
}
.cover article img {
width: 28%;
}
.cover article span {
font-size: 20vw;
}
@media (min-width: 42rem) {
.cover article, .cover article span {
font-size: 10rem;
}
}
.cover .btn-lg {
padding: .75rem 1.25rem;
font-weight: 700;
}
.mastfoot {
color: rgba(255, 255, 255, .5);
}

223
src/converter.ts Normal file
View File

@ -0,0 +1,223 @@
"use strict";
import { IncomingWebhookSendArguments, MessageAttachment } from "@slack/client";
import {
Button,
Card,
Image,
Section,
TextButton,
WidgetMarkup,
Message,
KeyValue,
CardHeader,
User,
TextParagraph,
OnClick,
OpenLink,
Icon,
ImageButton
} from "hangouts-chat-webhook";
export class Converter {
public static convert(slack: IncomingWebhookSendArguments): Message {
const message: Message = new Message();
if (!slack.attachments && slack.text) {
message.setText(slack.text);
} else if (slack.attachments) {
message.setPreviewText(slack.text);
if (slack.username) {
message.setSender(new User().setDisplayName(slack["username"]));
}
slack.attachments.forEach(attachment => {
const header: CardHeader = new CardHeader()
.setTitle(attachment["title"]);
if (attachment.fallback) {
message.setFallbackText(message.getFallbackText() ? attachment.fallback + "\n" : attachment.fallback);
message.setPreviewText(message.getFallbackText().trim());
}
if (attachment.pretext) {
header.setSubtitle(attachment.pretext);
}
const card: Card = new Card()
.setHeader(header);
const section: Section = new Section();
if (attachment.author_name) {
section.addWidget(Converter.author(attachment));
}
if (attachment.image_url) {
section.addWidget(Converter.image(attachment));
}
if (attachment.text) {
section.addWidget(Converter.text(attachment));
}
if (attachment.fields) {
attachment.fields.forEach(field => {
section.addWidget(Converter.field(field));
});
}
if (attachment.footer) {
section.addWidget(Converter.footer(attachment));
}
if (attachment.title_link) {
section.addWidget(Converter.bottomLink(attachment));
}
message.addCard(card.addSection(section));
});
}
return message;
}
private static image(attachment: MessageAttachment): WidgetMarkup {
return new WidgetMarkup()
.setImage(new Image()
.setImageUrl(attachment["image_url"])
);
}
private static formatText(text: string): string {
const match = /^>>>([\s\S]*)/gm.exec(text);
if (match && match.length > 0) {
text = text.replace(match[0], match[1]
.trim()
.replace(/\n/g, "\n" + '<font color="#e3e4e6">┃</font> ')
);
}
return text
.replace(/\*(.+?)\*/g, "<b>$1</b>")
.replace(/_(.+?)_/g, "<i>$1</i>")
.replace(/~(.+?)~/g, "<strike>$1</strike>")
.replace(/```(.+?)```/g, "<font color=\"#424242\">$1</font>")
.replace(/^>(.+?)/gm, "<font color=\"#e3e4e6\">┃</font> $1")
.replace(/`(.+?)`/g, "<font color=\"#d72b3f\">$1</font>")
.trim();
}
private static text(attachment: MessageAttachment): WidgetMarkup {
let color: String;
if (attachment.color) {
let hex: String = attachment.color;
if (attachment.color == "danger") {
hex = "#a30200";
} else if (attachment.color == "warning") {
hex = "#daa038";
} else if (attachment.color == "good") {
hex = "#2eb886";
} else if (attachment.color.substr(0, 1) != "#") {
hex = "#e8e8e8";
}
color = '<font color="' + hex + '"><b>▮</b></font> ';
}
return new WidgetMarkup()
.setTextParagraph(new TextParagraph()
.setText(color + Converter.formatText(attachment.text))
);
}
private static author(attachment: MessageAttachment): WidgetMarkup {
const widget: WidgetMarkup = new WidgetMarkup();
let onclick: OnClick;
if (attachment.author_link) {
onclick = new OnClick()
.setOpenLink(new OpenLink()
.setUrl(attachment.author_link)
);
}
if (attachment.author_icon) {
widget.addButton(new Button()
.setImageButton(new ImageButton()
.setIconUrl(attachment.author_icon)
.setName(attachment.author_name)
.setOnClick(onclick)
)
);
}
widget.addButton(new Button()
.setTextButton(new TextButton()
.setText(attachment.author_name)
.setOnClick(onclick)
)
);
return widget;
}
private static field(field: any): WidgetMarkup {
return new WidgetMarkup()
.setKeyValue(new KeyValue()
.setTopLabel(field["title"])
.setContent(field["value"])
.setContentMultiline(!field["short"])
.setIcon(Icon.DESCRIPTION)
);
}
private static footer(attachment: MessageAttachment): WidgetMarkup {
const footerLink: OnClick = new OnClick();
if (attachment.title_link) {
footerLink.setOpenLink(new OpenLink()
.setUrl(attachment.title_link)
);
}
const footer: WidgetMarkup = new WidgetMarkup();
if (attachment.footer_icon) {
footer.addButton(new Button()
.setImageButton(new ImageButton()
.setIconUrl(attachment.footer_icon)
.setName(attachment.footer)
.setOnClick(footerLink)
)
);
}
footer.addButton(new Button()
.setTextButton(new TextButton()
.setText(attachment.footer)
.setOnClick(footerLink)
)
);
return footer;
}
private static bottomLink(attachment: MessageAttachment): WidgetMarkup {
return new WidgetMarkup()
.addButton(new Button()
.setTextButton(new TextButton()
.setText("Open")
.setOnClick(new OnClick()
.setOpenLink(new OpenLink().
setUrl(attachment.title_link)
)
)
)
);
}
}

3
src/index.ts Normal file
View File

@ -0,0 +1,3 @@
export { Webhook } from "./webhook";
export { Converter } from "./converter";
export { app } from "./server";

79
src/server.ts Normal file
View File

@ -0,0 +1,79 @@
"use strict";
import url from "url";
import express, { Request, Response } from "express";
import compression from "compression";
import bodyParser from "body-parser";
import winston from "winston";
import expressWinston from "express-winston";
import errorHandler from "errorhandler";
import { Webhook } from "./webhook";
export const app = express();
app.set("port", process.env.PORT || 3000);
// request
app.use(compression());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: true}));
// logger
app.use((req, res, next) => {
Object.assign(res.locals, {
logUrl: url.parse(req.url).pathname
});
next();
});
const logger = winston.createLogger({
transports: [
new winston.transports.Console({})
],
format: winston.format.combine(
winston.format.splat(),
winston.format.colorize({}),
winston.format.timestamp(),
winston.format.printf(info => {
let meta: string = "";
if (info.meta && app.get("env") == "development") {
meta = JSON.stringify(info.meta || {}, undefined, 4);
}
return `${info.timestamp} ${info.level}: ${info.message} ${meta}`;
})
),
});
app.use(expressWinston.logger({
winstonInstance: logger,
msg: "{{req.method}} {{res.locals.logUrl}} {{res.statusCode}} {{res.responseTime}}ms",
colorize: true,
}));
// router
app.use(express.static("public"));
app.post("/:space", Webhook.express);
// errors
app.use(expressWinston.errorLogger({
winstonInstance: logger,
msg: "{{req.method}} {{res.locals.logUrl}} {{res.statusCode}} {{err.message}}"
}));
app.use(errorHandler({log: false}));
// docker exit
process.on("SIGINT", function() {
process.exit();
});
// listen
app.listen(app.get("port"), () => {
logger.info(
"App is running at http://localhost:%d in %s mode",
app.get("port"),
app.get("env")
);
});

115
src/webhook.ts Normal file
View File

@ -0,0 +1,115 @@
"use strict";
import * as url from "url";
import { Request, Response, NextFunction } from "express";
import * as superagent from "superagent";
import { Converter } from "./converter";
import { IncomingWebhookSendArguments } from "@slack/client";
export class WebhookResponse {
public status: number;
public headers: any;
public body: string;
constructor(status: number, headers: any, body: string) {
this.status = status;
this.headers = headers;
this.body = body;
}
}
export class WebhookError {
public status: number;
public headers: any;
public body: string;
public error: string;
constructor(status: number, headers: any, body: string, error: string) {
this.status = status;
this.headers = headers;
this.body = body;
this.error = error;
}
}
export class Webhook {
public static send(space: string, key: string, token: string, body: string): Promise<any> {
return new Promise((resolve, reject) => {
const webhookUrl: string = "https://chat.googleapis.com/v1/spaces/" + space + "/messages?key=" + key + "&token=" + token;
superagent.post(webhookUrl)
.set("Content-Type", "application/json; charset=UTF-8")
.send(Converter.convert(body as IncomingWebhookSendArguments))
.then((response: superagent.Response) => {
resolve(new WebhookResponse(
response.status,
response.header,
response.body
));
})
.catch((err: any) => {
reject(new WebhookError(
err.status,
err.header,
err.body,
err.response && err.response.text ? JSON.parse(err.response.text) : "Request failed",
));
});
});
}
public static express(req: Request, res: Response, next: NextFunction): void {
const parsed: url.UrlWithParsedQuery = url.parse(req.url, true);
Webhook.send(req.params["space"], <string>parsed.query.key, <string>parsed.query.token, req.body)
.then((value: WebhookResponse) => res
.status(value.status)
.json(value)
)
.catch((reason: WebhookError) => {
if (!(reason instanceof WebhookError)) {
return next(reason);
}
return res
.status(reason.status)
.json(reason);
});
}
public static async azure(context: any, req: any) {
const parsed = url.parse(req.url, true);
try {
const value: WebhookResponse = await Webhook.send(
<string> parsed.query.space,
<string> parsed.query.key,
<string> parsed.query.token,
req.body
);
context.res = {
status: value.status,
headers: {
"Content-Type": "application/json; charset=UTF-8"
},
body: value
};
} catch (reason) {
if (!(reason instanceof WebhookError)) {
context.res = {
status: 500,
body: reason.message
};
} else {
context.res = {
status: reason.status,
headers: {
"Content-Type": "application/json; charset=UTF-8"
},
body: reason
};
}
}
}
}

0
test/converter.test.js Normal file
View File

28
tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es6",
"noImplicitAny": true,
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"lib": [
"dom",
"es5",
"es2015",
"es2016.array.include",
"esnext.asynciterable"
],
"paths": {
"*": [
"node_modules/*",
"src/types/*"
]
}
},
"include": [
"src/**/*"
]
}

60
tslint.json Normal file
View File

@ -0,0 +1,60 @@
{
"rules": {
"class-name": true,
"comment-format": [
true,
"check-space"
],
"indent": [
true,
"spaces"
],
"one-line": [
true,
"check-open-brace",
"check-whitespace"
],
"no-var-keyword": true,
"quotemark": [
true,
"double",
"avoid-escape"
],
"semicolon": [
true,
"always",
"ignore-bound-class-methods"
],
"whitespace": [
true,
"check-branch",
"check-decl",
"check-operator",
"check-module",
"check-separator",
"check-type"
],
"typedef-whitespace": [
true,
{
"call-signature": "nospace",
"index-signature": "nospace",
"parameter": "nospace",
"property-declaration": "nospace",
"variable-declaration": "nospace"
},
{
"call-signature": "onespace",
"index-signature": "onespace",
"parameter": "onespace",
"property-declaration": "onespace",
"variable-declaration": "onespace"
}
],
"no-internal-module": true,
"no-trailing-whitespace": true,
"no-null-keyword": true,
"prefer-const": true,
"jsdoc-format": true
}
}