89 Commits
41 changed files with 2439 additions and 118 deletions
-
23.dockerignore
-
18.gitignore
-
5.helm/Chart.yaml
-
21.helm/templates/NOTES.txt
-
56.helm/templates/_helpers.tpl
-
68.helm/templates/deployment.yaml
-
41.helm/templates/ingress.yaml
-
23.helm/templates/persistentVolumeClaim.yaml
-
16.helm/templates/service.yaml
-
8.helm/templates/serviceaccount.yaml
-
15.helm/templates/tests/test-connection.yaml
-
79.helm/values.yaml
-
26Dockerfile
-
28README.md
-
4data/giphy-config.json.example
-
9data/szurubooru-config.json.example
-
20entrypoint.sh
-
54index.js
-
442package-lock.json
-
16package.json
-
264pipeline.yml
-
2scripts/get_build.sh
-
2scripts/get_tag.sh
-
2scripts/get_version.sh
-
27scripts/run_development_docker.sh
-
21scripts/run_development_local.sh
-
195src/bot.ts
-
29src/config.ts
-
172src/engine.ts
-
55src/logging.ts
-
28src/message.ts
-
260src/module/abstract.ts
-
65src/module/admin.ts
-
39src/module/example.ts
-
50src/module/giphy.ts
-
52src/module/help.ts
-
19src/module/index.ts
-
135src/module/szurubooru/client.ts
-
65src/module/szurubooru/index.ts
-
85src/utility.ts
-
14tsconfig.json
@ -0,0 +1,23 @@ |
|||
# Installed node files |
|||
node_modules/ |
|||
|
|||
# Compiled Files |
|||
dist/ |
|||
|
|||
# Logging files |
|||
log/ |
|||
|
|||
# Files open as swp in an editor |
|||
*.swp |
|||
|
|||
# Development / CICD / Deployment files |
|||
pipeline.yml |
|||
.helm/ |
|||
Dockerfile |
|||
|
|||
# Development documentation |
|||
README.md |
|||
CONTRIBUTING.md |
|||
|
|||
# Testing |
|||
test/ |
@ -1,8 +1,24 @@ |
|||
# Node Files |
|||
node_modules/ |
|||
|
|||
# Compiled Files |
|||
dist/ |
|||
|
|||
# Config files |
|||
data/config.json |
|||
data/*config.json |
|||
|
|||
# Log files |
|||
log/ |
|||
*.log |
|||
|
|||
# Mac Files |
|||
.DS_Store |
|||
|
|||
# Swap files |
|||
*.swp |
|||
|
|||
# Version files |
|||
build.info |
|||
version.info |
|||
tag.info |
|||
tag |
@ -0,0 +1,5 @@ |
|||
apiVersion: v1 |
|||
appVersion: "1.0" |
|||
description: A Helm chart for Kubernetes |
|||
name: baphomet-js |
|||
version: 0.1.2 |
@ -0,0 +1,21 @@ |
|||
1. Get the application URL by running these commands: |
|||
{{- if .Values.ingress.enabled }} |
|||
{{- range $host := .Values.ingress.hosts }} |
|||
{{- range .paths }} |
|||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} |
|||
{{- end }} |
|||
{{- end }} |
|||
{{- else if contains "NodePort" .Values.service.type }} |
|||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "baphomet-js.fullname" . }}) |
|||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") |
|||
echo http://$NODE_IP:$NODE_PORT |
|||
{{- else if contains "LoadBalancer" .Values.service.type }} |
|||
NOTE: It may take a few minutes for the LoadBalancer IP to be available. |
|||
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "baphomet-js.fullname" . }}' |
|||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "baphomet-js.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") |
|||
echo http://$SERVICE_IP:{{ .Values.service.port }} |
|||
{{- else if contains "ClusterIP" .Values.service.type }} |
|||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "baphomet-js.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") |
|||
echo "Visit http://127.0.0.1:8080 to use your application" |
|||
kubectl port-forward $POD_NAME 8080:80 |
|||
{{- end }} |
@ -0,0 +1,56 @@ |
|||
{{/* vim: set filetype=mustache: */}} |
|||
{{/* |
|||
Expand the name of the chart. |
|||
*/}} |
|||
{{- define "baphomet-js.name" -}} |
|||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} |
|||
{{- end -}} |
|||
|
|||
{{/* |
|||
Create a default fully qualified app name. |
|||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). |
|||
If release name contains chart name it will be used as a full name. |
|||
*/}} |
|||
{{- define "baphomet-js.fullname" -}} |
|||
{{- if .Values.fullnameOverride -}} |
|||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} |
|||
{{- else -}} |
|||
{{- $name := default .Chart.Name .Values.nameOverride -}} |
|||
{{- if contains $name .Release.Name -}} |
|||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}} |
|||
{{- else -}} |
|||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} |
|||
{{- end -}} |
|||
{{- end -}} |
|||
{{- end -}} |
|||
|
|||
{{/* |
|||
Create chart name and version as used by the chart label. |
|||
*/}} |
|||
{{- define "baphomet-js.chart" -}} |
|||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} |
|||
{{- end -}} |
|||
|
|||
{{/* |
|||
Common labels |
|||
*/}} |
|||
{{- define "baphomet-js.labels" -}} |
|||
app.kubernetes.io/name: {{ include "baphomet-js.name" . }} |
|||
helm.sh/chart: {{ include "baphomet-js.chart" . }} |
|||
app.kubernetes.io/instance: {{ .Release.Name }} |
|||
{{- if .Chart.AppVersion }} |
|||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} |
|||
{{- end }} |
|||
app.kubernetes.io/managed-by: {{ .Release.Service }} |
|||
{{- end -}} |
|||
|
|||
{{/* |
|||
Create the name of the service account to use |
|||
*/}} |
|||
{{- define "baphomet-js.serviceAccountName" -}} |
|||
{{- if .Values.serviceAccount.create -}} |
|||
{{ default (include "baphomet-js.fullname" .) .Values.serviceAccount.name }} |
|||
{{- else -}} |
|||
{{ default "default" .Values.serviceAccount.name }} |
|||
{{- end -}} |
|||
{{- end -}} |
@ -0,0 +1,68 @@ |
|||
apiVersion: apps/v1 |
|||
kind: Deployment |
|||
metadata: |
|||
name: {{ include "baphomet-js.fullname" . }} |
|||
labels: |
|||
{{ include "baphomet-js.labels" . | indent 4 }} |
|||
spec: |
|||
replicas: {{ .Values.replicaCount }} |
|||
selector: |
|||
matchLabels: |
|||
app.kubernetes.io/name: {{ include "baphomet-js.name" . }} |
|||
app.kubernetes.io/instance: {{ .Release.Name }} |
|||
template: |
|||
metadata: |
|||
labels: |
|||
app.kubernetes.io/name: {{ include "baphomet-js.name" . }} |
|||
app.kubernetes.io/instance: {{ .Release.Name }} |
|||
spec: |
|||
{{- with .Values.imagePullSecrets }} |
|||
imagePullSecrets: |
|||
{{- toYaml . | nindent 8 }} |
|||
{{- end }} |
|||
serviceAccountName: {{ template "baphomet-js.serviceAccountName" . }} |
|||
securityContext: |
|||
{{- toYaml .Values.podSecurityContext | nindent 8 }} |
|||
containers: |
|||
- name: {{ .Chart.Name }} |
|||
securityContext: |
|||
{{- toYaml .Values.securityContext | nindent 12 }} |
|||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" |
|||
imagePullPolicy: {{ .Values.image.pullPolicy }} |
|||
env: |
|||
- name: LOG_LEVEL |
|||
value: {{ .Values.app.env.log_level }} |
|||
- name: NODE_ENV |
|||
value: {{ .Values.app.env.node_env }} |
|||
resources: |
|||
{{- toYaml .Values.resources | nindent 12 }} |
|||
volumeMounts: |
|||
- mountPath: /opt/baphomet/data |
|||
name: data |
|||
subPath: data |
|||
- mountPath: /opt/baphomet/log |
|||
name: data |
|||
subPath: log |
|||
volumes: |
|||
- name: config |
|||
configMap: |
|||
name: {{ include "baphomet-js.fullname" . }} |
|||
- name: data |
|||
{{- if .Values.persistence.enabled }} |
|||
persistentVolumeClaim: |
|||
claimName: {{ .Values.persistence.existingClaim | default (include "baphomet-js.fullname" .) }} |
|||
{{- else }} |
|||
emptyDir: {} |
|||
{{- end -}} |
|||
{{- with .Values.nodeSelector }} |
|||
nodeSelector: |
|||
{{- toYaml . | nindent 8 }} |
|||
{{- end }} |
|||
{{- with .Values.affinity }} |
|||
affinity: |
|||
{{- toYaml . | nindent 8 }} |
|||
{{- end }} |
|||
{{- with .Values.tolerations }} |
|||
tolerations: |
|||
{{- toYaml . | nindent 8 }} |
|||
{{- end }} |
@ -0,0 +1,41 @@ |
|||
{{- if .Values.ingress.enabled -}} |
|||
{{- $fullName := include "baphomet-js.fullname" . -}} |
|||
{{- $svcPort := .Values.service.port -}} |
|||
{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} |
|||
apiVersion: networking.k8s.io/v1beta1 |
|||
{{- else -}} |
|||
apiVersion: extensions/v1beta1 |
|||
{{- end }} |
|||
kind: Ingress |
|||
metadata: |
|||
name: {{ $fullName }} |
|||
labels: |
|||
{{ include "baphomet-js.labels" . | indent 4 }} |
|||
{{- with .Values.ingress.annotations }} |
|||
annotations: |
|||
{{- toYaml . | nindent 4 }} |
|||
{{- end }} |
|||
spec: |
|||
{{- if .Values.ingress.tls }} |
|||
tls: |
|||
{{- range .Values.ingress.tls }} |
|||
- hosts: |
|||
{{- range .hosts }} |
|||
- {{ . | quote }} |
|||
{{- end }} |
|||
secretName: {{ .secretName }} |
|||
{{- end }} |
|||
{{- end }} |
|||
rules: |
|||
{{- range .Values.ingress.hosts }} |
|||
- host: {{ .host | quote }} |
|||
http: |
|||
paths: |
|||
{{- range .paths }} |
|||
- path: {{ . }} |
|||
backend: |
|||
serviceName: {{ $fullName }} |
|||
servicePort: {{ $svcPort }} |
|||
{{- end }} |
|||
{{- end }} |
|||
{{- end }} |
@ -0,0 +1,23 @@ |
|||
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} |
|||
kind: PersistentVolumeClaim |
|||
apiVersion: v1 |
|||
metadata: |
|||
name: {{ include "baphomet-js.fullname" . }} |
|||
labels: |
|||
app: {{ include "baphomet-js.fullname" . }} |
|||
chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" |
|||
release: "{{ .Release.Name }}" |
|||
heritage: "{{ .Release.Service }}" |
|||
annotations: |
|||
{{- if .Values.persistence.storageClass }} |
|||
volume.beta.kubernetes.io/storage-class: {{ .Values.persistence.storageClass | quote }} |
|||
{{- else }} |
|||
volume.alpha.kubernetes.io/storage-class: default |
|||
{{- end }} |
|||
spec: |
|||
accessModes: |
|||
- {{ .Values.persistence.accessMode | quote }} |
|||
resources: |
|||
requests: |
|||
storage: {{ .Values.persistence.size | quote }} |
|||
{{- end }} |
@ -0,0 +1,16 @@ |
|||
apiVersion: v1 |
|||
kind: Service |
|||
metadata: |
|||
name: {{ include "baphomet-js.fullname" . }} |
|||
labels: |
|||
{{ include "baphomet-js.labels" . | indent 4 }} |
|||
spec: |
|||
type: {{ .Values.service.type }} |
|||
ports: |
|||
- port: {{ .Values.service.port }} |
|||
targetPort: http |
|||
protocol: TCP |
|||
name: http |
|||
selector: |
|||
app.kubernetes.io/name: {{ include "baphomet-js.name" . }} |
|||
app.kubernetes.io/instance: {{ .Release.Name }} |
@ -0,0 +1,8 @@ |
|||
{{- if .Values.serviceAccount.create -}} |
|||
apiVersion: v1 |
|||
kind: ServiceAccount |
|||
metadata: |
|||
name: {{ template "baphomet-js.serviceAccountName" . }} |
|||
labels: |
|||
{{ include "baphomet-js.labels" . | indent 4 }} |
|||
{{- end -}} |
@ -0,0 +1,15 @@ |
|||
apiVersion: v1 |
|||
kind: Pod |
|||
metadata: |
|||
name: "{{ include "baphomet-js.fullname" . }}-test-connection" |
|||
labels: |
|||
{{ include "baphomet-js.labels" . | indent 4 }} |
|||
annotations: |
|||
"helm.sh/hook": test-success |
|||
spec: |
|||
containers: |
|||
- name: wget |
|||
image: busybox |
|||
command: ['wget'] |
|||
args: ['{{ include "baphomet-js.fullname" . }}:{{ .Values.service.port }}'] |
|||
restartPolicy: Never |
@ -0,0 +1,79 @@ |
|||
# Default values for baphomet-js. |
|||
# This is a YAML-formatted file. |
|||
# Declare variables to be passed into your templates. |
|||
|
|||
replicaCount: 1 |
|||
|
|||
image: |
|||
repository: nexus.nulloctet.com:5000/nulloctet/baphomet-js |
|||
tag: stable |
|||
pullPolicy: IfNotPresent |
|||
|
|||
app: |
|||
env: |
|||
log_level: "info" |
|||
node_env: "production" |
|||
|
|||
persistence: |
|||
accessMode: ReadWriteOnce |
|||
enabled: true |
|||
size: 10Mi |
|||
storageClass: storage |
|||
|
|||
imagePullSecrets: [] |
|||
nameOverride: "" |
|||
fullnameOverride: "" |
|||
|
|||
serviceAccount: |
|||
# Specifies whether a service account should be created |
|||
create: true |
|||
# The name of the service account to use. |
|||
# If not set and create is true, a name is generated using the fullname template |
|||
name: |
|||
|
|||
podSecurityContext: {} |
|||
# fsGroup: 2000 |
|||
|
|||
securityContext: {} |
|||
# capabilities: |
|||
# drop: |
|||
# - ALL |
|||
# readOnlyRootFilesystem: true |
|||
# runAsNonRoot: true |
|||
# runAsUser: 1000 |
|||
|
|||
service: |
|||
type: ClusterIP |
|||
port: 80 |
|||
|
|||
ingress: |
|||
enabled: false |
|||
annotations: {} |
|||
# kubernetes.io/ingress.class: nginx |
|||
# kubernetes.io/tls-acme: "true" |
|||
hosts: |
|||
- host: chart-example.local |
|||
paths: [] |
|||
|
|||
tls: [] |
|||
# - secretName: chart-example-tls |
|||
# hosts: |
|||
# - chart-example.local |
|||
|
|||
resources: {} |
|||
# We usually recommend not to specify default resources and to leave this as a conscious |
|||
# choice for the user. This also increases chances charts run on environments with little |
|||
# resources, such as Minikube. If you do want to specify resources, uncomment the following |
|||
# lines, adjust them as necessary, and remove the curly braces after 'resources:'. |
|||
# limits: |
|||
# cpu: 100m |
|||
# memory: 128Mi |
|||
# requests: |
|||
# cpu: 100m |
|||
# memory: 128Mi |
|||
|
|||
nodeSelector: {} |
|||
|
|||
tolerations: [] |
|||
|
|||
affinity: {} |
@ -0,0 +1,26 @@ |
|||
FROM node:12.14-stretch AS builder |
|||
WORKDIR /opt/baphomet |
|||
COPY . /opt/baphomet |
|||
|
|||
RUN cd /opt/baphomet \ |
|||
&& npm install \ |
|||
&& npm run build |
|||
|
|||
FROM node:12.14-stretch |
|||
WORKDIR /opt/baphomet |
|||
COPY . /opt/baphomet |
|||
|
|||
ENV NODE_ENV=production |
|||
ENV LOG_LEVEL=warn |
|||
|
|||
RUN rm -rf /opt/baphomet/src \ |
|||
&& mkdir /opt/baphomet/log |
|||
|
|||
COPY --from=builder /opt/baphomet/dist /opt/baphomet/dist |
|||
|
|||
RUN cd /opt/baphomet \ |
|||
&& npm install --only=prod \ |
|||
&& chmod +x entrypoint.sh |
|||
|
|||
ENTRYPOINT [ "./entrypoint.sh" ] |
|||
CMD [ "run" ] |
@ -0,0 +1,4 @@ |
|||
{ |
|||
"endpoint": "api.giphy.com/v1", |
|||
"apiKey": "<Your API Key Here>" |
|||
} |
@ -0,0 +1,9 @@ |
|||
{ |
|||
"url": "<api endpoint>", |
|||
"username": "<connecting username>", |
|||
"token": "<connection api token>", |
|||
"random_image_cache": {}, |
|||
"upload_rooms": { |
|||
"<authorized room to upload from>" |
|||
} |
|||
} |
@ -0,0 +1,20 @@ |
|||
#! /usr/bin/env sh |
|||
|
|||
DIR="$( cd "$( dirname "${0}" )" >/dev/null 2>&1 && pwd )" |
|||
export NODE_PATH="${DIR}" |
|||
|
|||
echo "NODE_ENV: ${NODE_ENV}" |
|||
echo "NODE_PATH: ${NODE_PATH}" |
|||
echo "LOG_LEVEL: ${LOG_LEVEL}" |
|||
|
|||
echo "Running build version: $(cat build.info)" |
|||
|
|||
case $1 in |
|||
run) |
|||
echo "Starting Baphomet..." |
|||
exec node index.js |
|||
;; |
|||
*) |
|||
echo "\"$1\" is an unrecognized command" |
|||
;; |
|||
esac |
@ -1,48 +1,10 @@ |
|||
let sdk = require("matrix-js-sdk"); |
|||
let config = require("./data/config.json"); |
|||
let bot = require('./dist/bot'); |
|||
let { getConfig } = require('./dist/config'); |
|||
let engine = require('./dist/engine'); |
|||
|
|||
console.log("Running with config:"); |
|||
console.log(config); |
|||
let config = getConfig(process.env.NODE_PATH + "/data/config.json", ['accessToken']) |
|||
|
|||
let client = sdk.createClient({ |
|||
baseUrl: config.baseUrl, |
|||
accessToken: config.accessToken, |
|||
userId: config.userId |
|||
}); |
|||
|
|||
function createBasicTextMessage(body) { |
|||
return { |
|||
"body": body, |
|||
"msgtype": "m.text" |
|||
} |
|||
} |
|||
|
|||
function sendClientStatusUpdate() { |
|||
config.statusRooms.forEach(roomId => { |
|||
console.log("Notifying %s", roomId); |
|||
client.sendMessage(roomId, createBasicTextMessage("Started!")).done(function() { |
|||
console.log("Notified %s", roomId); |
|||
}) |
|||
}); |
|||
} |
|||
|
|||
// Prep the bot
|
|||
client.on("sync", function (state, previousState, data) { |
|||
switch (state) { |
|||
case "PREPARED": |
|||
sendClientStatusUpdate(); |
|||
break; |
|||
} |
|||
}); |
|||
|
|||
// auto join rooms that an admin user has invited the bot to
|
|||
client.on("RoomMember.membership", function (event, member) { |
|||
if (member.membership === "invite" |
|||
&& config.admin.indexOf(ember.userId) >= 0) { |
|||
client.joinRoom(member.roomId).done(function () { |
|||
console.log("Auto-joined %s", member.roomId); |
|||
}); |
|||
} |
|||
}); |
|||
|
|||
client.startClient() |
|||
engine.create( |
|||
config, |
|||
bot.create(config) |
|||
).init().run(); |
@ -0,0 +1,2 @@ |
|||
#!/usr/bin/env sh |
|||
echo "$(git describe --tags --long)_$(date --rfc-3339=seconds | sed 's/ /T/g')" |
@ -0,0 +1,2 @@ |
|||
#!/usr/bin/env sh |
|||
echo "$(git describe --tags | awk '{split($0,a,"-"); print a[1]}')" |
@ -0,0 +1,2 @@ |
|||
#!/usr/bin/env sh |
|||
echo "$(git describe --tags --long)" |
@ -0,0 +1,27 @@ |
|||
#!/usr/bin/env bash |
|||
|
|||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" |
|||
pushd "${DIR}/.." |
|||
|
|||
echo "Writing local testing build.info" |
|||
./scripts/get_build.sh > build.info |
|||
echo "DOCKER_DEVELOPMENT_"$(cat build.info) > build.info |
|||
|
|||
CONTAINER_NAME=baphomet-dev |
|||
IMAGE_NAME=baphomet-js |
|||
IMAGE_TAG=dev |
|||
IMAGE_BUILD_DIR=. |
|||
|
|||
docker build -t ${IMAGE_NAME}:${IMAGE_TAG} ${IMAGE_BUILD_DIR} |
|||
|
|||
rm build.info |
|||
|
|||
docker rm ${CONTAINER_NAME} |
|||
|
|||
docker run -it \ |
|||
-e NODE_ENV=development \ |
|||
-e LOG_LEVEL=debug \ |
|||
--name ${CONTAINER_NAME} \ |
|||
${IMAGE_NAME}:${IMAGE_TAG} |
|||
|
|||
popd |
@ -0,0 +1,21 @@ |
|||
#!/usr/bin/env bash |
|||
|
|||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" |
|||
pushd "${DIR}/.." |
|||
|
|||
echo "Writing local testing build.info" |
|||
./scripts/get_build.sh > build.info |
|||
echo "LOCAL_DEVELOPMENT_"$(cat build.info) > build.info |
|||
|
|||
export NODE_ENV=development |
|||
export LOG_LEVEL=debug |
|||
|
|||
set -e |
|||
npm run build |
|||
set +e |
|||
|
|||
./entrypoint.sh run |
|||
|
|||
rm build.info |
|||
|
|||
popd |
@ -0,0 +1,195 @@ |
|||
let fs = require('fs'); |
|||
let sdk = require('matrix-js-sdk'); |
|||
let message = require('./message'); |
|||
let utility = require('./utility'); |
|||
let { logger } = require('./logging'); |
|||
|
|||
class Bot { |
|||
|
|||
client: any; |
|||
config: any; |
|||
buildInfo: string; |
|||
connected: boolean; |
|||
startTime: Date; |
|||
|
|||
constructor(config, buildInfo) { |
|||
this.config = config; |
|||
this.buildInfo = buildInfo; |
|||
this.connected = false; |
|||
this.startTime = new Date(); |
|||
} |
|||
|
|||
/** |
|||
* Initialize the bot connection |
|||
*/ |
|||
init(messageCallback: any) { |
|||
logger.info("Creating Matrix Client") |
|||
this.client = sdk.createClient({ |
|||
baseUrl: this.config.baseUrl, |
|||
userId: this.config.userId |
|||
}); |
|||
|
|||
this.client.on("sync", async (state: any, previousState: any, data: any) => { |
|||
switch (state) { |
|||
case "PREPARED": |
|||
this.connected = true; |
|||
await this.sendStatusStartup(); |
|||
await this.updateAvatar(process.env.NODE_PATH + '/assets/avatar.jpg'); |
|||
this.client.getJoinedRooms() |
|||
.done((rooms) => { |
|||
logger.info("Connected to: %o", rooms) |
|||
}); |
|||
break; |
|||
case "SYNCING": |
|||
logger.debug("Syncing") |
|||
break; |
|||
case "RECONNECTING": |
|||
logger.debug("Reconnecting"); |
|||
break; |
|||
default: |
|||
logger.error("Unexpected sync state: %s", state); |
|||
process.exit(1); |
|||
} |
|||
}); |
|||
|
|||
this.client.on("RoomMember.membership", (event: any, member: any) => { |
|||
if (member.membership === "invite" |
|||
&& this.config.admin.indexOf(member.userId) >= 0) { |
|||
this.client.joinRoom(member.roomId).done(() => { |
|||
logger.info("Auto-joined %s", member.roomId); |
|||
}); |
|||
} |
|||
}); |
|||
|
|||
this.client.on("Room.timeline", messageCallback); |
|||
|
|||
return this; |
|||
} |
|||
|
|||
async connect() { |
|||
// logger.info("Initializing Crypto");
|
|||
// await bot.client.initCrypto();
|
|||
let botClient = this.client; |
|||
let botConfig = this.config; |
|||
|
|||
let startServerConnection = () => { |
|||
logger.info("Starting Matrix SDK Client"); |
|||
return this.client.startClient({ |
|||
initialSyncLimit: 0 |
|||
}); |
|||
} |
|||
|
|||
let attemptPasswordLogin = (botClient, botConfig) => { |
|||
return botClient.loginWithPassword(botConfig.userId, botConfig.userPassword) |
|||
.then((data) => { |
|||
logger.info("Successfully authenticated with password %o", data); |
|||
return startServerConnection(); |
|||
}, (err) => { |
|||
logger.error("Failed to authenticate with password %o", err); |
|||
}); |
|||
} |
|||
|
|||
logger.info("Authenticating With Server"); |
|||
if (typeof botConfig.accessToken !== 'undefined') { |
|||
await botClient.loginWithToken(this.config.accessToken) |
|||
.then((data) => { |
|||
logger.info("Successfully authenticated with access token %o", data); |
|||
return startServerConnection(); |
|||
}, (err) => { |
|||
logger.error("Failed to authenticate with access token %o", err); |
|||
if (typeof botConfig.userPassword !== 'undefined') { |
|||
return attemptPasswordLogin(botClient, botConfig); |
|||
} else { |
|||
logger.error("No fallback password provided!"); |
|||
} |
|||
}); |
|||
} else if (typeof botConfig.userPassword !== 'undefined') { |
|||
await attemptPasswordLogin(botClient, botConfig); |
|||
} else { |
|||
logger.error("No authentication credentials available!"); |
|||
process.exit(1); |
|||
} |
|||
} |
|||
|
|||
updateAvatar(avatarFile, overwrite = false) { |
|||
let matrixClient = this.client; |
|||
let botConfig = this.config; |
|||
let promises = [Promise.resolve(true)]; |
|||
if (this.connected) { |
|||
promises.push(this.client.getProfileInfo(this.config.userId, "avatar_url") |
|||
.then((existingAvatarUrl) => { |
|||
logger.info("Recieved avatar_url: %o", existingAvatarUrl); |
|||
if (typeof existingAvatarUrl !== 'undefined' && typeof existingAvatarUrl == 'object' |
|||
&& existingAvatarUrl.constructor === Object && Object.keys(existingAvatarUrl).length !== 0 |
|||
&& !overwrite) { |
|||
logger.info("Avatar already set"); |
|||
} else { |
|||
logger.info("Setting avatar content from %s", avatarFile); |
|||
let avatarFileBuffer = fs.readFileSync(avatarFile); |
|||
logger.debug("Avatar Image Data %o", avatarFileBuffer); |
|||
matrixClient.uploadContent(avatarFileBuffer, { |
|||
name: botConfig.userId + " avatar", |
|||
type: "image/jpeg", |
|||
rawResponse: false |
|||
}).then((uploadedAvatar) => { |
|||
logger.info("Uploaded avatar %o", uploadedAvatar); |
|||
matrixClient.setAvatarUrl(uploadedAvatar.content_uri) |
|||
.then(() => { |
|||
logger.info("Updated %s avatar to %s", botConfig.userId, uploadedAvatar.content_uri); |
|||
return true; |
|||
}); |
|||
}); |
|||
} |
|||
})); |
|||
} else { |
|||
logger.warn("Attempting to update avatar while disconnected"); |
|||
} |
|||
return Promise.all(promises); |
|||
} |
|||
|
|||
sendStatusStartup() { |
|||
let promises = [Promise.resolve(true)] |
|||
if (this.connected) { |
|||
this.config.statusRooms.forEach(roomId => { |
|||
logger.debug("Notifying %s of startup", roomId); |
|||
promises.push(this.client.sendMessage( |
|||
roomId, message.createBasic(`Started at ${utility.toISODateString(this.startTime)} with version: ${this.buildInfo}`, message.types.NOTICE) |
|||
).then(() => { |
|||
logger.debug("Notified %s of startup", roomId); |
|||
}, (err) => { |
|||
logger.error("Unable to send message to room %s because %s", roomId, err.errcode); |
|||
})); |
|||
}); |
|||
} else { |
|||
logger.warn("Attempting to send startup message while disconnected"); |
|||
} |
|||
return Promise.all(promises); |
|||
} |
|||
|
|||
sendStatusShutdown() { |
|||
let promises = [Promise.resolve(true)] |
|||
if (this.connected) { |
|||
this.config.statusRooms.forEach(roomId => { |
|||
logger.debug("Notifying %s of shutdown", roomId); |
|||
promises.push(this.client.sendMessage( |
|||
roomId, message.createBasic(`Shutting down at ${utility.toISODateString(new Date())} with version: ${this.buildInfo}`, message.types.NOTICE) |
|||
).then(() => { |
|||
logger.debug("Notified %s of shutdown", roomId); |
|||
}, (err) => { |
|||
logger.error("Unable to send message to room %s because %s", roomId, err.errcode); |
|||
})); |
|||
}); |
|||
} else { |
|||
logger.warn("Attempting to send shutdown message while disconnected"); |
|||
} |
|||
return Promise.all(promises); |
|||
} |
|||
} |
|||
|
|||
function create(config) { |
|||
let buildInfo = utility.getBuildInfo(); |
|||
logger.info("Running version: %s", buildInfo); |
|||
return new Bot(config, buildInfo); |
|||
} |
|||
|
|||
export { create }; |
@ -0,0 +1,29 @@ |
|||
let fs = require('fs'); |
|||
let { logger } = require('./logging'); |
|||
|
|||
let loadedConfigs = new Map(); |
|||
|
|||
function sanitizeConfig(config: any, fields = []) : any { |
|||
let clonedConfig = { ...config }; |
|||
fields.forEach((field) => { |
|||
clonedConfig[field] = '******' |
|||
}) |
|||
return clonedConfig; |
|||
} |
|||
|
|||
function getConfig(configFile: string, sanitizedFields: Array<string> = [], reload: Boolean = false) : any { |
|||
if (loadedConfigs.has(configFile) && !reload) { |
|||
return loadedConfigs.get(configFile); |
|||
} else { |
|||
logger.info("Reading config: %s", configFile); |
|||
let rawConfigData = fs.readFileSync(configFile); |
|||
let config = JSON.parse(rawConfigData); |
|||
logger.info("Loaded config: %s", configFile); |
|||
logger.debug("%o", sanitizeConfig(config, sanitizedFields)); |
|||
loadedConfigs.set(configFile, config); |
|||
return config; |
|||
} |
|||
} |
|||
|
|||
export { getConfig }; |
|||
export { sanitizeConfig }; |
@ -0,0 +1,172 @@ |
|||
import { AbstractModule } from "./module/abstract"; |
|||
|
|||
let { logger } = require('./logging'); |
|||
let { modules } = require('./module/index'); |
|||
let trie = require('trie-prefix-tree'); |
|||
let { getShortestPrefix } = require('./utility'); |
|||
let help = require('./module/help'); |
|||
let message = require('./message'); |
|||
let utility = require('./utility'); |
|||
let { initModule } = require('./module/abstract'); |
|||
let { sanitizeConfig } = require('./config'); |
|||
|
|||
let commandPrefix = '!'; |
|||
|
|||
class Engine { |
|||
|
|||
config: any; |
|||
bot: any; |
|||
modules: Array<AbstractModule>; |
|||
moduleMap: Map<string, AbstractModule>; |
|||
commands: Array<string>; |
|||
commandMap: Map<string, AbstractModule>; |
|||
commandRadixTree: any; |
|||
helpModule: any; |
|||
|
|||
constructor(config, bot, modules) { |
|||
this.config = config; |
|||
this.bot = bot; |
|||
this.modules = modules; |
|||
this.moduleMap = new Map(); |
|||
this.commands = []; |
|||
this.commandMap = new Map(); |
|||
this.commandRadixTree = trie([]); |
|||
} |
|||
|
|||
initModules() { |
|||
let sanitizedGlobalConfig = sanitizeConfig( |
|||
this.config, |
|||
[ |
|||
'userPassword', |
|||
'accessToken' |
|||
]) |
|||
|
|||
this.modules.forEach((mod) => { |
|||
logger.info("Loading module: %s", mod.name); |
|||
initModule(mod, sanitizedGlobalConfig); |
|||
logger.info("Recognized commands: %s", mod.getRecognizedCommands()) |
|||
this.moduleMap.set(mod.command, mod); |
|||
this.commandMap.set(mod.command, mod); |
|||
this.commandRadixTree.addWord(mod.command); |
|||
}); |
|||
|
|||
this.modules.forEach((mod) => { |
|||
let shortCharCommand = getShortestPrefix(this.commandRadixTree, mod.command, 1); |
|||
let short3CharCommand = getShortestPrefix(this.commandRadixTree, mod.command, 3); |
|||
let shortCommandAliases = [shortCharCommand, short3CharCommand]; |
|||
logger.info("Adding short command %s for module: %s", shortCommandAliases, mod.name); |
|||
shortCommandAliases.forEach((commandAlias) => { |
|||
this.commandMap.set(commandAlias, mod); |
|||
}) |
|||
}); |
|||
|
|||
this.helpModule = help.create(this.moduleMap) |
|||
initModule(this.helpModule); |
|||
this.moduleMap.set(this.helpModule.command, this.helpModule); |
|||
this.commandMap.set('help', this.helpModule); |
|||
this.commands = Array.from(this.commandMap.keys()).sort() |
|||
|
|||
logger.info("Bound modules to keywords: %o", this.moduleMap); |
|||
} |
|||
|
|||
init() { |
|||
logger.info("Initializing modules"); |
|||
this.initModules(); |
|||
|
|||
/* Bind Message Parsing */ |
|||
let engine = this; |
|||
let handleEvent = (event, room, toStartOfTimeline) => { |
|||
/* Don't process messages from self */ |
|||
if (event.sender.userId !== this.config.userId) { |
|||
/* don't process messages that aren't of type m.room.message */ |
|||
if (event.getType() !== "m.room.message") { |
|||
logger.debug("Recieved message of type: %s", event.getType()); |
|||
return; |
|||
} else { |
|||
let messageBody = event.event.content.body; |
|||
logger.debug("[%s] %s", room.name, messageBody); |
|||
if (messageBody.indexOf(commandPrefix) === 0) { |
|||
let command = messageBody.split(' ')[0].substring(1); |
|||
if (engine.commandMap.has(command)) { |
|||
engine.commandMap.get(command).handleMessage(event, room, sendResponseMessageCallback(engine.bot)); |
|||
} else { |
|||
let responseMessage = "The following commands are recognized" |
|||
responseMessage += "\n" + engine.commands.join(", ") |
|||
responseMessage += "\nAdditional information can be discovered with !help <command>" |
|||
sendResponseMessage(engine.bot, room, responseMessage); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
this.bot.init(handleEvent); |
|||
|
|||
/* Capture Exit Conditions */ |
|||
|
|||
let signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; |
|||
signals.forEach((signature: NodeJS.Signals) => { |
|||
process.on(signature, async () => { |
|||
await this.bot.sendStatusShutdown() |
|||
.then(() => { |
|||
logger.info("Gracefully stopping Matrix SDK Client") |
|||
this.bot.client.stopClient(); |
|||
}); |
|||
process.exit(0); |
|||
}); |
|||
}); |
|||
|
|||
process.on('exit', () => { |
|||
logger.info("Shutting Down"); |
|||
}); |
|||
|
|||
return this; |
|||
} |
|||
|
|||
run() { |
|||
this.bot.connect(); |
|||
return this; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Handle the callback sending messages via the bot |
|||
* |
|||
* @param {*} bot |
|||
* @param {*} room |
|||
* @param {*} responseMessage |
|||
*/ |
|||
function sendResponseMessage(bot, room, responseMessage) { |
|||
logger.debug("Responding to room: %s with %o", room.roomId, responseMessage); |
|||
Promise.resolve(responseMessage).then((promisedMessage) => { |
|||
if (responseMessage !== null) { |
|||
logger.debug("Sending message: %s", promisedMessage); |
|||
if (promisedMessage instanceof Object) { |
|||
bot.client.sendMessage(room.roomId, promisedMessage); |
|||
} else if (utility.isString(promisedMessage)) { |
|||
bot.client.sendMessage(room.roomId, message.createBasic(promisedMessage)); |
|||
} else { |
|||
logger.error("Unable to process response message: %s", promisedMessage); |
|||
} |
|||
} else { |
|||
logger.warn("No response message offered"); |
|||
} |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* Wrapper to produce a callback function that can be passed to the modules |
|||
* |
|||
* @param {*} bot |
|||
*/ |
|||
function sendResponseMessageCallback(bot) { |
|||
return (room, responseMessage) => { |
|||
sendResponseMessage(bot, room, responseMessage); |
|||
} |
|||
} |
|||
|
|||
function create(config, bot) { |
|||
return new Engine(config, bot, modules) |
|||
} |
|||
|
|||
export { create }; |
@ -0,0 +1,55 @@ |
|||
let winston = require('winston'); |
|||
require('winston-daily-rotate-file'); |
|||
|
|||
let errorTransport = new (winston.transports.DailyRotateFile)({ |
|||
level: 'error', |
|||
filename: 'log/error-%DATE%.log', |
|||
datePattern: 'YYYY-MM-DD', |
|||
zippedArchive: true, |
|||
maxFiles: '7d' |
|||
}); |
|||
|
|||
let combinedTransport = new (winston.transports.DailyRotateFile)({ |
|||
filename: 'log/combined-%DATE%.log', |
|||
datePattern: 'YYYY-MM-DD', |
|||
zippedArchive: true, |
|||
maxFiles: '7d' |
|||
}); |
|||
|
|||
let logger = winston.createLogger({ |
|||
level: 'info', |
|||
format: winston.format.combine( |
|||
winston.format.timestamp(), |
|||
winston.format.splat(), |
|||
winston.format.simple() |
|||
), |
|||
defaultMeta: { service: 'baphomet-js' }, |
|||
transports: [ |
|||
errorTransport, |
|||
combinedTransport |
|||
] |
|||
}); |
|||
|
|||
if (process.env.NODE_ENV !== 'production') { |
|||
logger.add(new winston.transports.Console({ |
|||
format: winston.format.combine( |
|||
winston.format.colorize(), |
|||
winston.format.simple() |
|||
) |
|||
})); |
|||
} else { |
|||
logger.add(new winston.transports.Console({ |
|||
level: 'error', |
|||
format: winston.format.combine( |
|||
winston.format.colorize(), |
|||
winston.format.simple() |
|||
) |
|||
})); |
|||
} |
|||
|
|||
if ('LOG_LEVEL' in process.env) { |
|||
logger.info('LOG_LEVEL: %s', process.env.LOG_LEVEL) |
|||
logger.level = process.env.LOG_LEVEL |
|||
} |
|||
|
|||
export { logger }; |
@ -0,0 +1,28 @@ |
|||
let showdown = require('showdown'); |
|||
|
|||
let converter = new showdown.Converter(); |
|||
|
|||
enum MessageTypes { |
|||
TEXT = 'm.text', |
|||
NOTICE = 'm.notice' |
|||
} |
|||
|
|||
function createBasicMessage(body: string, msgtype: MessageTypes = MessageTypes.TEXT) { |
|||
return { |
|||
"body": body, |
|||
"msgtype": msgtype |
|||
}; |
|||
} |
|||
|
|||
function createMarkdownMessage(body: string, markdown: string, msgtype: MessageTypes = MessageTypes.TEXT) { |
|||
return { |
|||
"body": body, |
|||
"msgtype": msgtype, |
|||
"format": "org.matrix.custom.html", |
|||
"formatted_body": converter.makeHtml(markdown) |
|||
}; |
|||
} |
|||
|
|||
export { MessageTypes as types }; |
|||
export { createBasicMessage as createBasic }; |
|||
export { createMarkdownMessage as createMarkdown }; |
@ -0,0 +1,260 @@ |
|||
/** |
|||
* Base module that all modules extend |
|||
*/ |
|||
|
|||
let { logger } = require('../logging'); |
|||
let message = require('../message'); |
|||
let { isFunction, getObjectKeysToPrototype } = require('../utility'); |
|||
let { getConfig, sanitizeConfig } = require('../config'); |
|||
|
|||
class AbstractModule { |
|||
/* |
|||
Name of the module used in help documentation and logging. |
|||
*/ |
|||
name: string = "AbstractModule"; |
|||
|
|||
/* |
|||
Short description of the module functionality. |
|||
*/ |
|||
description: string = "Base Module That All Other Modules Extend"; |
|||
|
|||
/* |
|||
A helpful multiline string that defines the module usage |
|||
*/ |
|||
helpAndUsage: string = `Example: !abstract_module <command>
|
|||
!abstract_module <boo> : scares people`
|
|||
|
|||
/* |
|||
The exported command used to invoke the module directly. |
|||
*/ |
|||
command: string = "abstract_module"; |
|||
|
|||
/* |
|||
The default method to call when a command word is not recognized. |
|||
*/ |
|||
defaultCommand?: string = null; |
|||
|
|||
/* |
|||
The module should be hidden from help and command dialogs. |
|||
*/ |
|||
hidden: boolean = false; |
|||
|
|||
/* |
|||
This module should receive all messages, regardless of whether |
|||
the module was directly referenced with a command. |
|||
*/ |
|||
canHandleIndirectMessages: boolean = false; |
|||
|
|||
/* |
|||
Indicates if the modules needs access to the global config |
|||
*/ |
|||
needGlobalConfig: boolean = false; |
|||
|
|||
/* |
|||
Indicates if the module requires a readable config file. |
|||
*/ |
|||
needConfig: boolean = false; |
|||
|
|||
/* internal */ |
|||
|
|||
/* |
|||
The global config passed in |
|||
*/ |
|||
_global_config: any = null; |
|||
|
|||
/* |
|||
The loaded config file, if it exists. |
|||
*/ |
|||
_config: any = null; |
|||
|
|||
/** |
|||
* The recognized commands for the module |
|||
*/ |
|||
_recognizedCommands: Array<string> = []; |
|||
|
|||
/** |
|||
* The map of commands to functions |
|||
*/ |
|||
_recognizedCommandMap: Map<string, string> = new Map(); |
|||
|
|||
constructor(name: string, description: string, command: string) { |
|||
this.name = name; |
|||
this.description = description; |
|||
this.command = command; |
|||
} |
|||
|
|||
/** |
|||
* Called after the module is initialized. |
|||
*/ |
|||
postInit() : any { |
|||
return this; |
|||
} |
|||
|
|||
/** |
|||
* Adds a recognized command and method to the module. |
|||
* |
|||
* @param {*} command |
|||
* @param {*} methodName |
|||
*/ |
|||
addRecognizedCommand(command: string, methodName: string) { |
|||
this._recognizedCommands.push(command); |
|||
this._recognizedCommandMap.set(command, methodName); |
|||
} |
|||
|
|||
/** |
|||
* Returns the list of recognized commands. |
|||
*/ |
|||
getRecognizedCommands() : Iterable<string> { |
|||
return this._recognizedCommandMap.keys(); |
|||
} |
|||
|
|||
/** |
|||
* The file path that the module configuration file is expected at. |
|||
*/ |
|||
getConfigFilePath() : string { |
|||
return process.env.NODE_PATH + '/data/' + this.name.toLowerCase().replace(' ', '_') + '-config.json'; |
|||
} |
|||
|
|||
/** |
|||
* Fields that should be sanitized before printing the config information for the user. |
|||
*/ |
|||
getConfigSensitiveFields() : Array<string> { |
|||
return []; |
|||
} |
|||
|
|||
/** |
|||
* Return a global config value or a default. |
|||
* |
|||
* @param {*} key |
|||
* @param {*} defaultValue |
|||
*/ |
|||
getGlobal(key: string, defaultValue: string = null) { |
|||
if (this._global_config !== null && typeof this._global_config[key] !== 'undefined') { |
|||
return this._global_config[key]; |
|||
} |
|||
return defaultValue |
|||
} |
|||
|
|||
/** |
|||
* Return a module config value or a default. |
|||
* |
|||
* @param {*} key |
|||
* @param {*} defaultValue |
|||
*/ |
|||
get(key: string, defaultValue: string = null) { |
|||
if (this._config !== null && typeof this._config[key] !== 'undefined') { |
|||
return this._config[key]; |
|||
} |
|||
return defaultValue |
|||
} |
|||
|
|||
/** |
|||
* Default functionality for receiving and processing a message. |
|||
* |
|||
* Override this if the module needs to do more complicated message processing. |
|||
*/ |
|||
handleMessage(event: any, room: any, callback: CallableFunction) { |
|||
logger.debug("[%s] [%s] [%s]", this.name, room.name, event.event.content.body); |
|||
|
|||
let messageBody = event.event.content.body; |
|||
let bodyParts = messageBody.split(' '); |
|||
let trigger = bodyParts[0]; |
|||
let command = bodyParts[1]; |
|||
var args = []; |
|||
if (bodyParts.length > 2) { |
|||
args = bodyParts.slice(2); |
|||
} |
|||
|
|||
logger.debug("[%s] Attempting to call %s with %s", trigger, command, args); |
|||
let responseMessage = this.processMessage(event, command, ...args); |
|||
|
|||
callback( |
|||
room, |
|||
responseMessage |
|||
); |
|||
} |
|||
|
|||
/* |
|||
Call the command method with the args |
|||
*/ |
|||
processMessage(event: any, command: string, ...args: Array<string>) { |
|||
if (this._recognizedCommands.includes(command)) { |
|||
logger.debug("Calling %s with %s", this._recognizedCommandMap.get(command), args); |
|||
return this[this._recognizedCommandMap.get(command)](event, ...args); |
|||
} else { |
|||
if (this.defaultCommand != null) { |
|||
logger.debug("Attempting to use default command %s", this.defaultCommand); |
|||
let newArgs = [command].concat(...args); |
|||
try { |
|||
logger.debug("Calling %s with %s", this._recognizedCommandMap.get(this.defaultCommand), newArgs); |
|||
return this[this._recognizedCommandMap.get(this.defaultCommand)](event, ...newArgs); |
|||
} catch (e) { |
|||
logger.error("Error while calling default command %s %s", this.defaultCommand, e); |
|||
return this.cmd_help(event, ...newArgs); |
|||
} |
|||
} else { |
|||
logger.debug("Unrecognized command %s", command); |
|||
return this.cmd_help(event, ...[command].concat(...args)); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/* Basic cmd methods */ |
|||
|
|||
/* |
|||
return basic help information,. |
|||
*/ |
|||
cmd_help(event: any, ...args: Array<string>) { |
|||
return message.createBasic(this.helpAndUsage); |
|||
} |
|||
|
|||
/** |
|||
* Return the basic config file |
|||
* |
|||
* @param {...any} args |
|||
*/ |
|||
cmd_config(event: any, ...args: Array<string>) { |
|||
if (this._config != null) { |
|||
let configBody = JSON.stringify(sanitizeConfig(this._config, this.getConfigSensitiveFields()), null, 2) |
|||
return message.createMarkdown(configBody, "```" + configBody + "```"); |
|||
} |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
let abstractModulePrototype = Object.getPrototypeOf(new AbstractModule('', '', '')); |
|||
|
|||
/* |
|||
Initialization of a module. |
|||
*/ |
|||
function init(mod: AbstractModule, globalConfig: any) { |
|||
logger.debug("Initializing module %s", mod.name); |
|||
if (mod.needConfig) { |
|||
logger.debug("Loading config file %s", mod.getConfigFilePath()); |
|||
try { |
|||
mod._config = getConfig(mod.getConfigFilePath(), mod.getConfigSensitiveFields()); |
|||
} catch (e) { |
|||
logger.error("Module %s needs a valid config file at %s", mod.name, mod.getConfigFilePath()); |
|||
process.exit(1); |
|||
} |
|||
} |
|||
if (mod.needGlobalConfig) { |
|||
logger.debug("Bound global config to module %s", mod.name); |
|||
mod._global_config = globalConfig; |
|||
} |
|||
logger.debug("Detecting command methods."); |
|||
let commandMethods = getObjectKeysToPrototype(mod, abstractModulePrototype, (key) => { |
|||
return key.startsWith('cmd_') && isFunction(mod[key]); |
|||
}) |
|||
// let commandMethods = objectKeys.filter();
|
|||
logger.debug("Identified command methods: %s", commandMethods); |
|||
commandMethods.forEach((commandMethodName) => { |
|||
let command = commandMethodName.substring(4); |
|||
mod.addRecognizedCommand(command, commandMethodName); |
|||
}) |
|||
logger.debug("Bound command methods for %s as %s", mod.name, mod.getRecognizedCommands()); |
|||
mod.postInit(); |
|||
} |
|||
|
|||
export { AbstractModule }; |
|||
export { init as initModule }; |
@ -0,0 +1,65 @@ |
|||
/** |
|||
* Administration module |
|||
*/ |
|||
|
|||
let { AbstractModule } = require('./abstract'); |
|||
let { logger } = require('../logging'); |
|||
let { sanitizeConfig } = require('../config'); |
|||
let message = require('../message'); |
|||
import { getBuildInfo } from '../utility'; |
|||
|
|||
class AdminModule extends AbstractModule { |
|||
constructor() { |
|||
super( |
|||
"Administration", |
|||
"Support administration tasks", |
|||
"admin" |
|||
); |
|||
this.hidden = true; |
|||
this.helpAndUsage = `Usage: admin <command>
|
|||
admin config - print the bot configuration`;
|
|||
this.needGlobalConfig = true; |
|||
} |
|||
|
|||
/** |
|||
* Override to only permit recognized admin users to access the plugin |
|||
*/ |
|||
handleMessage(event: any, room: any, callback: CallableFunction) { |
|||
if (this.getGlobal("admins", []).includes(event.sender.userId)) { |
|||
logger.debug("Authorized %s for admin action", event.sender.userId); |
|||
super.handleMessage(event, room, callback); |
|||
} else { |
|||
logger.warn("User %s tried to access admin functionality", event.sender.userId); |
|||
return event.sender.userId + " is not a recognized admin!" |
|||
} |
|||
} |
|||
|
|||
/* Commands |
|||
* All methods starting with cmd_ will be parsed at initialization to expose those methods as commdands to the user |
|||
*/ |
|||
|
|||
/** |
|||
* Print the current config information |
|||
* |
|||
* @param {...any} args |
|||
*/ |
|||
cmd_config(event: any, ...args: Array<string>) { |
|||
if (this._global_config != null) { |
|||
let configBody = JSON.stringify(sanitizeConfig(this._global_config), null, 2) |
|||
return message.createMarkdown(configBody, "```" + configBody + "```"); |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
/** |
|||
* Return the verion of the running bot |
|||
* |
|||
* @param event |
|||
* @param args |
|||
*/ |
|||
cmd_version(event: any, ...args: Array<string>) { |
|||
return getBuildInfo(); |
|||
} |
|||
} |
|||
|
|||
export { AdminModule as Module }; |
@ -0,0 +1,39 @@ |
|||
/** |
|||
* Example module |
|||
* Copy this, replace the relevent parts, add it to the list in index.js |
|||
*/ |
|||
|
|||
let { AbstractModule } = require('./abstract'); |
|||
|
|||
class ExampleModule extends AbstractModule { |
|||
constructor() { |
|||
super( |
|||
"Example", |
|||
"Example tasks", |
|||
"example" |
|||
); |
|||
this.helpAndUsage = `Usage: example <command>
|
|||
...`;
|
|||
this.hidden = false; //default value
|
|||
this.needGlobalConfig = false; // default value
|
|||
this.needConfig = false; // default value
|
|||
this.defaultCommand = 'search'; // name of the command to call when no recognized command name is present
|
|||
} |
|||
|
|||
/* Commands |
|||
* All methods starting with cmd_ will be parsed at initialization to expose those methods as commdands to the user |
|||
*/ |
|||
|
|||
/** |
|||
* Processed when !example blah is processed |
|||
* |
|||
* @param {...any} args the individual words passed after the command |
|||
*/ |
|||
cmd_blah(event, ...args) { |
|||
return "Bla blah bla" |
|||
} |
|||
} |
|||
|
|||
let module = new ExampleModule(); |
|||
|
|||
export { module }; |
@ -0,0 +1,50 @@ |
|||
/** |
|||
* Giphy module |
|||
*/ |
|||
|
|||
let { AbstractModule } = require('./abstract'); |
|||
let axios = require('axios'); |
|||
let { logger } = require('../logging'); |
|||
|
|||
class GiphyModule extends AbstractModule { |
|||
constructor() { |
|||
super( |
|||
"Giphy", |
|||
"Insert Giphy Links/Media", |
|||
"giphy" |
|||
); |
|||
this.helpAndUsage = `Usage: !giphy itsworking
|
|||
...`;
|
|||
this.needConfig = true; |
|||
this.defaultCommand = 'search'; |
|||
} |
|||
|
|||
getConfigSensitiveFields(): Array<string> { |
|||
return ["apiKey"]; |
|||
} |
|||
|
|||
getGiphySearch(term: string) { |
|||
let url = this.get("endpoint") + '/gifs/search?api_key=' + this.get("apiKey") + '&q=' + term + '&limit=1'; |
|||
logger.debug("Requesting: %s", url.replace(this.get("apiKey"), '******')); |
|||
return axios.get(url); |
|||
} |
|||
|
|||
/* Commands |
|||
* All methods starting with cmd_ will be parsed at initialization to expose those methods as commdands to the user |
|||
*/ |
|||
|
|||
/** |
|||
* Return the top item for the search terms. |
|||
* |
|||
* @param {...any} args |
|||
*/ |
|||
cmd_search(event: any, ...args: Array<string>) { |
|||
return this.getGiphySearch(args[0]) |
|||
.then((response) => { |
|||
// logger.debug("Giphy response: %o", response.data.data[0].url);
|
|||
return response.data.data[0].embed_url; |
|||
}) |
|||
} |
|||
} |
|||
|
|||
export { GiphyModule as Module }; |
@ -0,0 +1,52 @@ |
|||
/** |
|||
* Help module |
|||
*/ |
|||
|
|||
let { AbstractModule } = require('./abstract'); |
|||
let { logger } = require('../logging'); |
|||
|
|||
class HelpModule extends AbstractModule { |
|||
constructor(commandMap: Map<string, CallableFunction>) { |
|||
super( |
|||
"Help", |
|||
"Provide helpful information about other modules.", |
|||
"help" |
|||
); |
|||
this._commandMap = commandMap; |
|||
this._commandList = Array.from(commandMap.keys()).sort(); |
|||
this.defaultCommand = 'help'; |
|||
} |
|||
|
|||
_default_help_message(): string { |
|||
let help = `!help <command>`; |
|||
for (let command of this._commandList) { |
|||
help += "\n!help " + command + " : " + this._commandMap.get(command).description; |
|||
} |
|||
return help; |
|||
} |
|||
|
|||
/* Commands */ |
|||
|
|||
cmd_help(event: any, ...args: Array<string>) { |
|||
logger.debug("%o", args) |
|||
if (args.length < 1) { |
|||
return this._default_help_message(); |
|||
} else { |
|||
let command = args[0]; |
|||
logger.debug("Looking up help for %s from %o", command, this._commandMap); |
|||
if (this._commandList.includes(command)) { |
|||
return this._commandMap.get(command).cmd_help(); |
|||
} else { |
|||
let help = command + " is an unrecognized module\n"; |
|||
help += this._default_help_message(); |
|||
return help; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
function create(commandMap: Map<string, CallableFunction>) { |
|||
return new HelpModule(commandMap); |
|||
} |
|||
|
|||
export { create }; |
@ -0,0 +1,19 @@ |
|||
/** |
|||
* Manage the registered modules |
|||
*/ |
|||
|
|||
let admin = require('./admin'); |
|||
let giphy = require('./giphy') |
|||
let szurubooru = require('./szurubooru/index'); |
|||
|
|||
function getModules() { |
|||
return [ |
|||
new admin.Module(), |
|||
new giphy.Module(), |
|||
new szurubooru.Module() |
|||
]; |
|||
} |
|||
|
|||
let modules = getModules(); |
|||
|
|||
export { modules }; |
@ -0,0 +1,135 @@ |
|||
/** |
|||
* Szurubooru api client |
|||
*/ |
|||
|
|||
let axios = require('axios'); |
|||
let { logger } = require('../../logging'); |
|||
|
|||
function getRandomInt(min, max) { |
|||
min = Math.ceil(min); |
|||
max = Math.floor(max); |
|||
return Math.floor(Math.random() * (max - min)) + min; //The maximum is exclusive and the minimum is inclusive
|
|||
} |
|||
|
|||
export class SzurubooruClient { |
|||
|
|||
url: string; |
|||
baseUrl: string; |
|||
username: string; |
|||
token: string; |
|||
|
|||
randomImageCount: number; |
|||
|
|||
_randomSearchQueryValues: string = "type:image,animated,video sort:random"; |
|||
_randomSearchUrlTemplate: string = "%s/posts/?offset=%s&limit=%d&query=%s"; |
|||
|
|||
constructor(url: string, username: string, token: string) { |
|||
this.url = url; |
|||
this.baseUrl = url.replace('/api', ''); |
|||
this.username = username; |
|||
this.token = token; |
|||
this.updateRandomScope(); |
|||
} |
|||
|
|||
getRandomSearchUrl(offset: number, limit: number, tags: Array<string> = []) { |
|||
var searchQuery = this._randomSearchQueryValues; |
|||
if (tags !== null && tags.length > 0) { |
|||
searchQuery = this._randomSearchQueryValues + tags.join(" "); |
|||
} |
|||
return this.url + '/posts/?offset=' + offset + '&limit=' + limit + '&query=' + searchQuery; |
|||
} |
|||
|
|||
/** |
|||
* Determine a sane range of offset values |
|||
* |
|||
* @param {*} limit The content limit of a single page |
|||
* @param {*} total The number of objects to search through |
|||
*/ |
|||
getValidOffsetRange(limit: number, total: number): Array<number> { |
|||
let offsetOverflow = total % limit; |
|||
var offsetCeiling = (Math.round(total / limit)); |
|||
if (offsetOverflow === 0) { |
|||
offsetCeiling -= 1; |
|||
} |
|||
return [0, offsetCeiling]; |
|||
} |
|||
|
|||
|
|||
/** |
|||
* Determine a random offset based on the potential random search size |
|||
* |
|||
* Because Szurubooru doesn't randomize each sort:random search request, we must |
|||
* manually calculate a random offset for each request to simulate randomness in the |
|||
* posts returned. |
|||
* |
|||
* @param {*} self |
|||
* @param {*} imageCount |
|||
*/ |
|||
getRandomSearchParameters(imageCount?: number) { |
|||
let resultLimit = 10; |
|||
var offsetRange = [0, 1]; |
|||
if (imageCount === null) { |
|||
offsetRange = this.getValidOffsetRange(resultLimit, this.randomImageCount); |
|||
} else { |
|||
offsetRange = this.getValidOffsetRange(resultLimit, imageCount); |
|||
} |
|||
return [resultLimit, getRandomInt(offsetRange[0], offsetRange[1])]; |
|||
} |
|||
|
|||
|
|||
async updateRandomScope() { |
|||
let client = this; |
|||
let target = this.getRandomSearchUrl(0, 1); |
|||
await this.makeGetRequest(target).then((result: any) => { |
|||
client.randomImageCount = result.total; |
|||
}); |
|||
|
|||
} |
|||
|
|||
makeGetRequest(url: string) { |
|||
logger.debug("Making get request to %s", url); |
|||
return axios.get(url, { |
|||
headers: { |
|||
"Authorization": "Token " + Buffer.from(this.username + ':' + this.token).toString('base64'), |
|||
"Accept": "application/json" |
|||
} |
|||
}).then((response: any) => { |
|||
logger.debug("Recieved: %o", response.data); |
|||
return response.data; |
|||
}, (err: any) => { |
|||
logger.error("Unexpected client error: %o", err); |
|||
return null; |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Return a random post matching the optionally provided tags. |
|||
* |
|||
* @param {*} self |
|||
* @param {*} tags |
|||
*/ |
|||
getRandomPost(tags: Array<string> = []) { |
|||
let client = this; |
|||
var resultLimit = 0; |
|||
var randomSearchOffset = 0; |
|||
if (tags.length === 0) { |
|||
let randomSearchParameters = this.getRandomSearchParameters(); |
|||
resultLimit = randomSearchParameters[0]; |
|||
randomSearchOffset = randomSearchParameters[1]; |
|||
} |
|||
let searchUrl = this.getRandomSearchUrl(randomSearchOffset, resultLimit, tags); |
|||
return this.makeGetRequest(searchUrl) |
|||
.then((data: any) => { |
|||
if (data !== null) { |
|||
let limitSpace = Math.min(resultLimit, data.results.length - 1); |
|||
let randomPost = data.results[getRandomInt(0, limitSpace)]; |
|||
return client.baseUrl + '/' + randomPost.contentUrl; |
|||
} else { |
|||
return null |
|||
} |
|||
}, (err: any) => { |
|||
logger.error("Unexpected error: %o", err); |
|||
return null; |
|||
}) |
|||
} |
|||
} |
@ -0,0 +1,65 @@ |
|||
/** |
|||
* Module for interacting with a Szurubooru instance |
|||
*/ |
|||
|
|||
let { AbstractModule } = require('../abstract'); |
|||
let { logger } = require('../../logging'); |
|||
import { SzurubooruClient } from './client'; |
|||
|
|||
class SzurubooruModule extends AbstractModule { |
|||
|
|||
client: SzurubooruClient; |
|||
|
|||
constructor() { |
|||
super( |
|||
"Szurubooru", |
|||
"Interact with a Szurubooru instance", |
|||
"szurubooru" |
|||
); |
|||
this.helpAndUsage = `Usage:
|
|||
'!szurubooru <tag> [<tag>...]' display a random post that matches tags |
|||
'!szurubooru config' prints config information for the room |
|||
'!szurubooru config upload' toggles uploading images from room messages |
|||
'!szurubooru iqdb' get an IQDB link for the last displayed post |
|||
'!szurubooru need [<max tag count (5)>]' show a random post that needs additional tags |
|||
'!szurubooru post <post_id>' retrieve a post and publish the image to the room |
|||
'!szurubooru random [<tag>...]' display a random image from szurubooru that matches tags if preset |
|||
'!szurubooru random update' update the random counts |
|||
'!szurubooru recent [<days> (1)]' display a recent random image within the last <days> |
|||
'!szurubooru related' display related posts to the last displayed post |
|||
'!szurubooru tag <tag> [<tag>...]' add tags to the last displayed post |
|||
'!szurubooru tag clone <post_id>' clone tags from <post_id> to the last displayed post |
|||
'!szurubooru tag remove <tag> [<tag>...]' remove tags from the last displayed post |
|||
'!szurubooru tags' list of all tags by popularity |
|||
'!szurubooru tags <tag_stub> [<tag_stub>...]' list tags similar to each <tag_stub> |
|||
'!szurubooru upload <url>' upload a remote file to szurubooru`;
|
|||
this.needConfig = true; |
|||
this.defaultCommand = 'random'; |
|||
} |
|||
|
|||
postInit() { |
|||
super.postInit(); |
|||
this.client = new SzurubooruClient( |
|||
this.get("url"), |
|||
this.get("username"), |
|||
this.get("token") |
|||
) |
|||
} |
|||
|
|||
getConfigSensitiveFields() { |
|||
return ["token"]; |
|||
} |
|||
|
|||
/* Commands |
|||
* All methods starting with cmd_ will be parsed at initialization to expose those methods as commdands to the user |
|||
*/ |
|||
|
|||
cmd_random(event: any, ...args: Array<string>) { |
|||
return this.client.getRandomPost().then((data) => { |
|||
logger.info("Random post: %o", data); |
|||
return data; |
|||
}); |
|||
} |
|||
} |
|||
|
|||
export { SzurubooruModule as Module }; |
@ -0,0 +1,85 @@ |
|||
let fs = require('fs'); |
|||
let { logger } = require('./logging'); |
|||
|
|||
function getShortestPrefix(radixTree: any, key: string, sliceSize: number) { |
|||
let shortKey = key.substring(0, sliceSize); |
|||
let keyCount = radixTree.countPrefix(shortKey); |
|||
if (keyCount < 1) { |
|||
return null; |
|||
} |
|||
if (key.length === sliceSize && radixTree.getPrefix(shortKey).includes(key)) { |
|||
return null; |
|||
} |
|||
if (keyCount === 1) { |
|||
return shortKey; |
|||
} |
|||
return getShortestPrefix(radixTree, key, sliceSize + 1); |
|||
} |
|||
|
|||
function toISODateString(d: Date) { |
|||
function pad(n: number) { return n < 10 ? '0' + n : n } |
|||
return d.getUTCFullYear() + '-' |
|||
+ pad(d.getUTCMonth() + 1) + '-' |
|||
+ pad(d.getUTCDate()) + 'T' |
|||
+ pad(d.getUTCHours()) + ':' |
|||
+ pad(d.getUTCMinutes()) + ':' |
|||
+ pad(d.getUTCSeconds()) + 'Z' |
|||
} |
|||
|
|||
function getBuildInfo() { |
|||
let buildInfoPath = process.env.NODE_PATH + '/build.info'; |
|||
try { |
|||
return fs.readFileSync(buildInfoPath, "utf8").trim(); |
|||
} catch (err) { |
|||
if (err.code === 'ENOENT') { |
|||
return "UNKNOWN_" + toISODateString(new Date()); |
|||
} else { |
|||
logger.error("Unexpected Error!", err); |
|||
} |
|||
} |
|||
} |
|||
|
|||
function sleep(ms: number) { |
|||
return new Promise(resolve => { |
|||
setTimeout(resolve, ms) |
|||
}) |
|||
} |
|||
|
|||
function isString(s: any) { |
|||
return typeof (s) === 'string' || s instanceof String; |
|||
} |
|||
|
|||
function isFunction(f: any) { |
|||
return f && {}.toString.call(f) === '[object Function]'; |
|||
} |
|||
|
|||
/** |
|||
* Parse the prototype tree to return all accessible properties till |
|||
* reaching a sentinelPrototype. |
|||
* |
|||
* Optionally provide a filtering function to return only the names that match. |
|||
* |
|||
* @param {*} initialObj The starting object to derive the from |
|||
* @param {*} sentinelPrototype The prototype that represents the end of the line |
|||
* @param {*} filterFunc A filtering function for the return names |
|||
*/ |
|||
function getObjectKeysToPrototype(initialObj: any, sentinelPrototype: any, filterFunc: any = (e: any) => true) { |
|||
let prototypeChain = [] |
|||
var targetPrototype = initialObj; |
|||
while (Object.getPrototypeOf(targetPrototype) && targetPrototype !== sentinelPrototype) { |
|||
targetPrototype = Object.getPrototypeOf(targetPrototype); |
|||
prototypeChain.push(targetPrototype); |
|||
} |
|||
let completePropertyNames = prototypeChain.map((obj) => { |
|||
return Object.getOwnPropertyNames(obj); |
|||
}) |
|||
return [Object.getOwnPropertyNames(initialObj)].concat.apply([], completePropertyNames).filter(filterFunc); |
|||
} |
|||
|
|||
export { getShortestPrefix }; |
|||
export { toISODateString }; |
|||
export { getBuildInfo }; |
|||
export { sleep }; |
|||
export { isString }; |
|||
export { isFunction }; |
|||
export { getObjectKeysToPrototype }; |
@ -0,0 +1,14 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"outDir": "./dist", |
|||
"allowJs": false, |
|||
"target": "ES2015", |
|||
"module": "CommonJS", |
|||
"typeRoots": [ |
|||
"node_modules/@types" |
|||
] |
|||
}, |
|||
"include": [ |
|||
"./src/**/*" |
|||
] |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue