Initial sticker pack creation

This commit is contained in:
Travis Ralston 2018-04-07 22:49:49 -06:00
parent 2d9d47bdad
commit bac85a44a2
21 changed files with 2896 additions and 1 deletions

10
.gitignore vendored
View file

@ -1,3 +1,13 @@
config/*
!config/default.yaml
lib/
storage/
/.idea
/db
*.db
# Logs
logs
*.log

13
.travis.yml Normal file
View file

@ -0,0 +1,13 @@
language: node_js
node_js:
- "9"
env:
- NODE_ENV=development
before_install:
- npm i -g npm
install:
- npm install
script:
- npm run build
- npm run lint

View file

@ -1,2 +1,21 @@
# matrix-sticker-bot
A bot to help people create their own sticker packs
[![#stickerbot:t2bot.io](https://img.shields.io/badge/matrix-%23stickerbot:t2bot.io-brightgreen.svg)](https://matrix.to/#/#stickerbot:t2bot.io)
[![TravisCI badge](https://travis-ci.org/turt2live/matrix-sticker-bot.svg?branch=master)](https://travis-ci.org/turt2live/matrix-sticker-bot)
A matrix bot to allow users to make their own sticker packs.
# Usage
1. Invite `@stickers:t2bot.io` to a private chat
2. Send the message `!stickers help` to get information about how to use the bot
# Building your own
*Note*: You'll need to have access to an account that the bot can use to get the access token.
1. Clone this repository
2. `npm install`
3. `npm run build`
4. Copy `config/default.yaml` to `config/production.yaml`
5. Run the bot with `NODE_ENV=production node index.js`

35
config/default.yaml Normal file
View file

@ -0,0 +1,35 @@
homeserverUrl: "https://t2bot.io"
accessToken: "YOUR_TOKEN_HERE"
# The webserver settings
webserver:
port: 8082
bind: 0.0.0.0
# The public URL is used to provide links to users who generate sticker packs
publicUrl: 'https://stickers.t2bot.io/'
# Media repository settings
media:
# Set to true to clone uploaded media on your media repository.
# Requires turt2live/matrix-media-repo
useLocalCopy: false
# Set to true to use the server-side media info to check the images.
# Requires turt2live/matrix-media-repo
useMediaInfo: false
# The database options
database:
# The relative path to the sqlite database
file: "stickerbot.db"
# Settings for controlling how logging works
logging:
file: logs/stickerbot.log
console: true
consoleLevel: info
fileLevel: verbose
rotate:
size: 52428800 # bytes, default is 50mb
count: 5

2162
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

37
package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "matrix-sticker-bot",
"version": "1.0.0",
"description": "A matrix bot to allow user-generated sticker packs to be created",
"main": "./lib/index.js",
"author": "Travis Ralston",
"license": "GPL-3.0",
"repository": {
"type": "git",
"url": "git+https://github.com/turt2live/matrix-sticker-bot.git"
},
"dependencies": {
"@types/node": "^9.4.6",
"config": "^1.29.4",
"express": "^4.16.3",
"js-yaml": "^3.10.0",
"matrix-bot-sdk": "^0.1.8",
"matrix-js-snippets": "^0.2.5",
"mkdirp": "^0.5.1",
"node-localstorage": "^1.3.0",
"random-string": "^0.2.0",
"reflect-metadata": "^0.1.12",
"sequelize": "^4.37.6",
"sequelize-typescript": "^0.6.3",
"sqlite": "^2.9.1",
"striptags": "^3.1.1",
"umzug": "^2.1.0"
},
"devDependencies": {
"tslint": "^5.9.1",
"typescript": "^2.7.2"
},
"scripts": {
"build": "tsc",
"lint": "tslint --project ./tsconfig.json --type-check -t stylish"
}
}

View file

@ -0,0 +1,29 @@
import { StickerPackBuilder } from "../builders/builder";
class _BuilderRegistry {
private roomIdToBuilder: { [roomId: string]: StickerPackBuilder } = {};
public register(roomId: string, builder: StickerPackBuilder): void {
if (this.roomIdToBuilder[roomId]) {
throw new Error("An operation is already in progress");
}
this.roomIdToBuilder[roomId] = builder;
}
public deregister(roomId: string): void {
delete this.roomIdToBuilder[roomId];
}
public handleEvent(roomId: string, event: any): Promise<any> {
if (this.roomIdToBuilder[roomId]) {
return this.roomIdToBuilder[roomId].handleEvent(event);
} else return Promise.resolve();
}
public hasBuilder(roomId: string): boolean {
return !!this.roomIdToBuilder[roomId];
}
}
export const BuilderRegistry = new _BuilderRegistry();

View file

@ -0,0 +1,29 @@
import { IFilterInfo, IStorageProvider } from "matrix-bot-sdk";
import { LocalStorage } from "node-localstorage";
import * as mkdirp from "mkdirp";
export class LocalstorageStorageProvider implements IStorageProvider {
private kvStore: Storage;
constructor(path: string) {
mkdirp.sync(path);
this.kvStore = new LocalStorage(path, 100 * 1024 * 1024); // quota is 100mb
}
setSyncToken(token: string | null): void {
this.kvStore.setItem("sync_token", token);
}
getSyncToken(): string | null {
return this.kvStore.getItem("sync_token");
}
setFilter(filter: IFilterInfo): void {
// Do nothing
}
getFilter(): IFilterInfo {
return null;
}
}

View file

@ -0,0 +1,110 @@
import { MatrixClient } from "matrix-bot-sdk";
import { StickerPackBuilder } from "./builder";
import config from "../config";
import { LogService } from "matrix-js-snippets";
export interface Sticker {
description: string;
contentUri: string;
}
export class GatherStickersStage implements StickerPackBuilder {
public stickers: Sticker[] = [];
private currentSticker: Sticker = {description: "", contentUri: ""};
private expectingImage = true;
private resolveFn: (stickers: Sticker[]) => void;
constructor(private client: MatrixClient, private roomId: string) {
}
public start(): Promise<Sticker[]> {
return new Promise((resolve, _reject) => {
this.resolveFn = resolve;
});
}
public async handleEvent(event: any): Promise<any> {
if (event['type'] === "m.room.message" && event['content']['msgtype'] === "m.text") {
if (event['content']['body'] === "!done") {
LogService.info("GatherStickersStage", "Finished sticker gathering for " + this.roomId);
this.resolveFn(this.stickers);
return Promise.resolve();
}
if (!this.expectingImage) {
this.currentSticker.description = event['content']['body'];
this.stickers.push(this.currentSticker);
this.currentSticker = {description: "", contentUri: ""};
this.expectingImage = true;
LogService.info("GatherStickersStage", "A sticker has been completed, but not submitted in " + this.roomId);
return this.client.sendNotice(this.roomId, "Thanks! Send me another 512x512 PNG for your next sticker or say !done if you've finished.");
}
}
if (event['type'] !== "m.room.message" || !event['content']['url'] || event['content']['msgtype'] !== "m.image") {
LogService.warn("GatherStickersStage", "Event does not look to be an image event in " + this.roomId);
return this.client.sendNotice(this.roomId, "That doesn't look like an image to me. Please send a 512x512 PNG of the sticker you'd like to add.");
}
const mxc = event['content']['url'];
if (!mxc.startsWith("mxc://")) {
LogService.warn("GatherStickersStage", "Not an MXC URI in " + this.roomId);
return this.client.sendNotice(this.roomId, "That doesn't look like a valid image, sorry.");
}
const mxcParts = mxc.substring("mxc://".length).split("/");
const origin = mxcParts[0];
const mediaId = mxcParts[1];
if (!origin || !mediaId) {
LogService.warn("GatherStickersStage", "Invalid format for content URI in " + this.roomId);
return this.client.sendNotice(this.roomId, "That doesn't look like a valid image, sorry.");
}
if (config.media.useMediaInfo) {
try {
LogService.info("GatherStickersStage", "Requesting media info for " + mxc);
const response = await this.client.doRequest("GET", "/_matrix/media/r0/info/" + origin + "/" + mediaId);
if (response['content_type'] !== "image/png" || !response['width'] || !response['height']) {
LogService.warn("GatherStickersStage", "Media info for " + mxc + " indicates the file is invalid in " + this.roomId);
return this.client.sendNotice(this.roomId, "Please upload a PNG image for your sticker.");
}
} catch (err) {
LogService.error("GatherStickersStage", "Error requesting media info:");
LogService.error("GatherStickersStage", err);
return this.client.sendNotice(this.roomId, "Something went wrong while checking your sticker. Please try again.");
}
} else {
if (!event['content']['info']) {
LogService.warn("GatherStickersStage", "Event is missing media info in " + this.roomId);
return this.client.sendNotice(this.roomId, "Your client didn't send me enough information for me to validate your sticker. Please try again or use a different client.");
}
if (event['content']['info']['mimetype'] !== "image/png") {
LogService.warn("GatherStickersStage", "Media info from event indicates the file is not an image in " + this.roomId);
return this.client.sendNotice(this.roomId, "Please upload a PNG image for your sticker.");
}
}
let contentUri = "mxc://" + origin + "/" + mediaId;
if (config.media.useLocalCopy) {
try {
LogService.info("GatherStickersStage", "Requesting local copy of " + contentUri);
const response = await this.client.doRequest("GET", "/_matrix/media/r0/local_copy/" + origin + "/" + mediaId);
contentUri = response["content_uri"];
LogService.info("GatherStickersStage", "Local copy for " + mxc + " is " + contentUri);
} catch (err) {
LogService.error("GatherStickersStage", "Error getting local copy:");
LogService.error("GatherStickersStage", err);
return this.client.sendNotice(this.roomId, "Something went wrong with handling your sticker. Please try again.");
}
}
this.currentSticker = {
description: "",
contentUri: contentUri,
};
this.expectingImage = false;
LogService.info("GatherStickersStage", "Asking for a description for the uploaded image in " + this.roomId);
return this.client.sendNotice(this.roomId, "Great! In a few words, please describe your sticker.");
}
}

View file

@ -0,0 +1,58 @@
import { StickerPackBuilder } from "./builder";
import { MatrixClient } from "matrix-bot-sdk";
import { GatherStickersStage, Sticker } from "./GatherStickersStage";
import * as randomString from "random-string";
import Stickerpack from "../db/models/Stickerpack";
import StickerRecord from "../db/models/StickerRecord";
import config from "../config";
export class NewPackBuilder implements StickerPackBuilder {
private name: string;
private expectingName = true;
private gatherStage: GatherStickersStage;
constructor(private client: MatrixClient, private roomId: string) {
client.sendNotice(roomId, "Woot! A new sticker pack. What should we call it?");
this.gatherStage = new GatherStickersStage(client, roomId);
}
public async handleEvent(event: any): Promise<any> {
if (this.expectingName) {
if (event['type'] !== "m.room.message" || !event['content']['body'] || event['content']['msgtype'] !== "m.text") {
return this.client.sendNotice(this.roomId, "Not quite the type of name I was expecting. Let's start with what we should call your new pack. To give it a name, just send me a message with your pack's name.");
}
this.name = event['content']['body'];
this.expectingName = false;
this.gatherStage.start().then(stickers => this.createStickerPack(stickers));
return this.client.sendNotice(this.roomId, "Thanks! Now send me your first sticker. The image should be a PNG image (with a transparent background) and should be 512x512.\n\nThe sticker should also have a white border around it.");
} else {
return this.gatherStage.handleEvent(event);
}
}
private async createStickerPack(stickers: Sticker[]): Promise<any> {
const members = await this.client.getJoinedRoomMembers(this.roomId);
const selfId = await this.client.getUserId();
const creatorId = members.filter(m => m !== selfId)[0];
if (!creatorId) throw new Error("Could not find a user ID to own this sticker pack");
const packId = (randomString({length: 10}) + "-" + this.name.replace(/[^a-zA-Z0-9-]/g, '-')).substring(0, 30);
const pack = await Stickerpack.create({id: packId, creatorId: creatorId, name: this.name});
for (const sticker of stickers) {
await StickerRecord.create({
packId: packId,
description: sticker.description,
contentUri: sticker.contentUri,
});
}
const slug = `${creatorId}/${packId}`;
const baseUrl = config.webserver.publicUrl;
const url = (baseUrl.endsWith("/") ? baseUrl : baseUrl + "/") + slug;
return this.client.sendNotice(this.roomId, "Awesome! I've created your sticker pack and published it here: " + url);
}
}

3
src/builders/builder.ts Normal file
View file

@ -0,0 +1,3 @@
export interface StickerPackBuilder {
handleEvent(event: any): Promise<any>;
}

22
src/config.ts Normal file
View file

@ -0,0 +1,22 @@
import * as config from "config";
import { LogConfig } from "matrix-js-snippets";
interface IConfig {
homeserverUrl: string;
accessToken: string;
webserver: {
port: number;
bind: string;
publicUrl: string;
};
media: {
useLocalCopy: boolean;
useMediaInfo: boolean;
};
database: {
file: string;
};
logging: LogConfig;
}
export default <IConfig>config;

43
src/db/StickerStore.ts Normal file
View file

@ -0,0 +1,43 @@
import { Model, Sequelize } from "sequelize-typescript";
import { LogService } from "matrix-js-snippets";
import config from "../config";
import * as path from "path";
import * as Umzug from "umzug";
import Stickerpack from "./models/Stickerpack";
import Sticker from "./models/StickerRecord";
class _StickerStore {
private sequelize: Sequelize;
constructor() {
this.sequelize = new Sequelize({
dialect: 'sqlite',
database: "stickerbot",
storage: config.database.file,
username: "",
password: "",
logging: i => LogService.verbose("StickerStore [SQL]", i)
});
this.sequelize.addModels(<Array<typeof Model>>[
Sticker,
Stickerpack,
]);
}
public updateSchema(): Promise<any> {
LogService.info("StickerStore", "Updating schema...");
const migrator = new Umzug({
storage: "sequelize",
storageOptions: {sequelize: this.sequelize},
migrations: {
params: [this.sequelize.getQueryInterface()],
path: path.join(__dirname, "migrations"),
},
});
return migrator.up();
}
}
export const StickerStore = new _StickerStore();

View file

@ -0,0 +1,29 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("stickerpacks", {
"id": {type: DataType.STRING, primaryKey: true, allowNull: false},
"creatorId": {type: DataType.STRING, allowNull: false},
"name": {type: DataType.STRING, allowNull: false},
}))
.then(() => queryInterface.createTable("stickers", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"packId": {
type: DataType.STRING,
allowNull: false,
references: {model: "stickerpacks", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
"description": {type: DataType.STRING, allowNull: false},
"contentUri": {type: DataType.STRING, allowNull: false},
}));
},
down: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.dropTable("stickers"))
.then(() => queryInterface.dropTable("stickerpacks"));
}
}

View file

@ -0,0 +1,23 @@
import { Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript";
import Stickerpack from "./Stickerpack";
@Table({
tableName: "stickers",
underscoredAll: false,
timestamps: false,
})
export default class StickerRecord extends Model<StickerRecord> {
@PrimaryKey
@Column
id: number;
@Column
@ForeignKey(() => Stickerpack)
packId: string;
@Column
description: string;
@Column
contentUri: string;
}

View file

@ -0,0 +1,18 @@
import { Column, Model, PrimaryKey, Table } from "sequelize-typescript";
@Table({
tableName: "stickerpacks",
underscoredAll: false,
timestamps: false,
})
export default class Stickerpack extends Model<Stickerpack> {
@PrimaryKey
@Column
id: string;
@Column
creatorId: string;
@Column
name: string;
}

41
src/index.ts Normal file
View file

@ -0,0 +1,41 @@
import { AutojoinRoomsMixin, MatrixClient, SimpleRetryJoinStrategy } from "matrix-bot-sdk";
import config from "./config";
import { LogService } from "matrix-js-snippets";
import { CommandProcessor } from "./matrix/CommandProcessor";
import { LocalstorageStorageProvider } from "./bot/LocalstorageStorageProvider";
import { BuilderRegistry } from "./bot/BuilderRegistry";
import { StickerStore } from "./db/StickerStore";
import { Webserver } from "./web/webserver";
LogService.configure(config.logging);
const storageProvider = new LocalstorageStorageProvider("./storage");
const client = new MatrixClient(config.homeserverUrl, config.accessToken, storageProvider);
const commands = new CommandProcessor(client);
async function run() {
await StickerStore.updateSchema();
Webserver.start();
const userId = await client.getUserId();
client.on("room.message", (roomId, event) => {
if (event['sender'] === userId) return;
if (!event['content']) return;
if (event['type'] !== "m.room.message" && event['type'] !== "m.sticker") return; // Everything we care about is a message or sticker
const isText = event['content']['msgtype'] === "m.text";
const isCommand = isText && (event['content']['body'] || "").toString().startsWith("!stickers");
if (BuilderRegistry.hasBuilder(roomId) && !isCommand) {
return BuilderRegistry.handleEvent(roomId, event);
}
return commands.tryCommand(roomId, event);
});
AutojoinRoomsMixin.setupOnClient(client);
client.setJoinStrategy(new SimpleRetryJoinStrategy());
return client.start();
}
run().then(() => LogService.info("index", "Sticker bot started!"));

View file

@ -0,0 +1,54 @@
import { MatrixClient } from "matrix-bot-sdk";
import { LogService } from "matrix-js-snippets";
import * as striptags from "striptags";
import { BuilderRegistry } from "../bot/BuilderRegistry";
import { NewPackBuilder } from "../builders/NewPackBuilder";
export class CommandProcessor {
constructor(private client: MatrixClient) {
}
public async tryCommand(roomId: string, event: any): Promise<any> {
const message = event['content']['body'];
if (!message || !message.startsWith("!stickers")) return;
LogService.info("CommandProcessor", "Received command - checking room members");
const members = await this.client.getJoinedRoomMembers(roomId);
if (members.length > 2) {
return this.client.sendNotice(roomId, "It is best to interact with me in a private room.");
}
let command = "help";
const args = message.substring("!stickers".length).trim().split(" ");
if (args.length > 0) {
command = args[0];
args.splice(0, 1);
}
if (command === "newpack") {
if (BuilderRegistry.hasBuilder(roomId)) {
return this.client.sendNotice(roomId, "Oops! It looks like you're already doing something. Please finish your current operation before creating a new sticker pack.");
}
BuilderRegistry.register(roomId, new NewPackBuilder(this.client, roomId)); // sends a welcome message
} else if (command === "cancel") {
if (BuilderRegistry.hasBuilder(roomId)) {
BuilderRegistry.deregister(roomId);
return this.client.sendNotice(roomId, "Your current operation has been canceled");
} else return this.client.sendNotice(roomId, "There's nothing for me to cancel");
} else {
const htmlMessage = "<p>Sticker bot help:<br /><pre><code>" +
`!stickers newpack - Create a new sticker pack\n` +
`!stickers cancel - Cancels whatever operation you're doing\n` +
"!stickers help - This menu\n" +
"</code></pre></p>" +
"<p>For help or more information, visit <a href='https://matrix.to/#/#help:t2bot.io'>#help:t2bot.io</a></p>";
return this.client.sendMessage(roomId, {
msgtype: "m.notice",
body: striptags(htmlMessage),
format: "org.matrix.custom.html",
formatted_body: htmlMessage,
});
}
}
}

70
src/web/webserver.ts Normal file
View file

@ -0,0 +1,70 @@
import * as express from "express";
import config from "../config";
import { LogService } from "matrix-js-snippets";
import Stickerpack from "../db/models/Stickerpack";
import StickerRecord from "../db/models/StickerRecord";
class _Webserver {
private app: any;
constructor() {
this.app = express();
this.app.get('/:userId/:packId', this.getStickerpack.bind(this));
// TODO: Serve a home page of some kind. Stickerpack browser?
}
public start() {
this.app.listen(config.webserver.port, config.webserver.bind, () => {
LogService.info("webserver", "Listening on " + config.webserver.bind + ":" + config.webserver.port);
});
}
private async getStickerpack(req, res) {
const accept = (req.headers['accept'] || "");
const params = (req.params || {});
const userId = params['userId'];
let packId = params['packId'];
const replyJson = accept.indexOf("application/json") !== -1 || packId.endsWith(".json");
if (packId.endsWith(".json")) packId = packId.substring(0, packId.length - ".json".length);
const pack = await Stickerpack.findOne({where: {creatorId: userId, id: packId}});
if (!pack) {
// TODO: A real 404 page
res.status(404);
res.send({"TODO": "A real 404 page"});
return;
}
const stickers = await StickerRecord.findAll({where: {packId: pack.id}});
if (replyJson) {
LogService.info("Webserver", "Serving JSON for pack " + pack.id);
res.send({
version: 1,
type: "m.stickerpack",
name: pack.name,
id: pack.id, // arbitrary
author: {
type: "mx-user",
id: pack.creatorId,
},
stickers: stickers.map(s => {
return {
id: s.id, // arbitrary
description: s.description,
contentUri: s.contentUri,
};
}),
});
return;
}
// TODO: A real preview page
res.send({"TODO": "A real preview page"});
}
}
export const Webserver = new _Webserver();

18
tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2015",
"noImplicitAny": false,
"sourceMap": true,
"outDir": "./lib",
"types": [
"node"
]
},
"include": [
"./src/**/*"
]
}

72
tslint.json Normal file
View file

@ -0,0 +1,72 @@
{
"rules": {
"class-name": false,
"comment-format": [
true
],
"curly": false,
"eofline": false,
"forin": false,
"indent": [
true,
"spaces"
],
"label-position": true,
"max-line-length": false,
"member-access": false,
"member-ordering": [
true,
"static-after-instance",
"variables-before-functions"
],
"no-arg": true,
"no-bitwise": false,
"no-console": false,
"no-construct": true,
"no-debugger": true,
"no-duplicate-variable": true,
"no-empty": false,
"no-eval": true,
"no-inferrable-types": true,
"no-shadowed-variable": true,
"no-string-literal": false,
"no-switch-case-fall-through": true,
"no-trailing-whitespace": true,
"no-unused-expression": true,
"no-use-before-declare": false,
"no-var-keyword": true,
"object-literal-sort-keys": false,
"one-line": [
true,
"check-open-brace",
"check-catch",
"check-else",
"check-whitespace"
],
"quotemark": false,
"radix": true,
"semicolon": [
"always"
],
"triple-equals": [],
"typedef-whitespace": [
true,
{
"call-signature": "nospace",
"index-signature": "nospace",
"parameter": "nospace",
"property-declaration": "nospace",
"variable-declaration": "nospace"
}
],
"variable-name": false,
"whitespace": [
true,
"check-branch",
"check-decl",
"check-operator",
"check-separator",
"check-type"
]
}
}