Compare commits
81 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cc86b52fd | |||
| b7e398a068 | |||
| bd80d562c7 | |||
| 9871ed8930 | |||
| 5db585a525 | |||
| ff8e571950 | |||
| 7eeff2faf3 | |||
| b869b432b6 | |||
| 44fb3f9f64 | |||
| e47b5e3d2b | |||
| 50d09fd48f | |||
| 85314818d2 | |||
| b3badac452 | |||
| 3df15c5efa | |||
| c53b54bafc | |||
| 1ea9712086 | |||
| b924de2357 | |||
| 98240400a6 | |||
| e23d365913 | |||
| 6c2aeea8a6 | |||
| f3330826d9 | |||
| afa8ba2237 | |||
| 86cfbd21a9 | |||
| 438ed2b4eb | |||
| 56f7c4c09a | |||
| e807d1fbf2 | |||
| 748e851b39 | |||
| b38abe81a6 | |||
| b84b848d04 | |||
| 20ce420303 | |||
| a877122ef6 | |||
| de6ce38c2d | |||
| b90592cbe9 | |||
| 8260396254 | |||
| c691274dd9 | |||
| 5a0e7f6a66 | |||
| 69b128a598 | |||
| 7895f89cc0 | |||
| 10fbb9e696 | |||
| edfbdc567f | |||
| c9509bb938 | |||
| 42c32ba749 | |||
| ffed434c6a | |||
| 0557c7b143 | |||
| 3e42616065 | |||
| 22ff10222c | |||
| 3f7a7aa10f | |||
| 266f46563b | |||
| 06962c217e | |||
| 9424b5e517 | |||
| 9bf6e50ae9 | |||
| fa916699a7 | |||
| bea0b9370d | |||
| c283528d72 | |||
| 0ad4b41ae9 | |||
| 4a26001382 | |||
| f0515ceecf | |||
| 69d07c1a7b | |||
| 3aa5f1b7ce | |||
| e146faced1 | |||
| 23cdf54982 | |||
| b53b2f56b6 | |||
| d7aadc3079 | |||
| f9e303f018 | |||
| e44f1041b6 | |||
| f734b0619f | |||
| b542a81ee1 | |||
| ac421e6c74 | |||
| 7afcbfaa06 | |||
| abe42aaa92 | |||
| aaf8dea104 | |||
| 486959be0b | |||
| 01b82e7b68 | |||
| dca53752bb | |||
| 8676a73620 | |||
| 10099142c2 | |||
| aedd30ab4a | |||
| a66b93ed26 | |||
| 5a853249a2 | |||
| 2c7831c587 | |||
| ae6b730c26 |
37 changed files with 1258 additions and 601 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,17 +1,18 @@
|
|||
# Secrets
|
||||
# Personal
|
||||
config.js
|
||||
registration.yaml
|
||||
ooye.db*
|
||||
events.db*
|
||||
backfill.db*
|
||||
custom-webroot
|
||||
icon.svg
|
||||
.devcontainer
|
||||
|
||||
# Automatically generated
|
||||
node_modules
|
||||
coverage
|
||||
test/res/*
|
||||
!test/res/lottie*
|
||||
icon.svg
|
||||
*~
|
||||
.#*
|
||||
\#*#
|
||||
|
|
|
|||
9
docs/threads-as-rooms.md
Normal file
9
docs/threads-as-rooms.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
I thought pretty hard about it and I opted to make threads separate rooms because
|
||||
|
||||
1. parity: discord has separate things like permissions and pins for threads, matrix cannot do this at all unless the thread is a separate room
|
||||
2. usage styles: most discord threads I've seen tend to be long-lived, spanning months or years, which isn't suited to matrix because of the timeline
|
||||
- I'm in a discord thread for posting photos of food that gets a couple posts a week and has a timeline going back to 2023
|
||||
3. the timeline: if a matrix room has threads, and you want to scroll back through the timeline of a room OR of one of its threads, the timeline is merged, so you have to download every message linearised and throw them away if they aren't part of the thread you're looking through. it's bad for threads and it's bad for the main room
|
||||
4. it is also very very complex for clients to implement read receipts and typing indicators correctly for the merged timeline. if your client doesn't implement this, or doesn't do it correctly, you have a bad experience. many clients don't. element seems to have done it well enough, but is an exception
|
||||
|
||||
overall in my view, threads-as-rooms has better parity and fewer downsides over native threads. but if there are things you don't like about this approach, I'm happy to discuss and see if we can improve them.
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "out-of-your-element",
|
||||
"version": "3.5.1",
|
||||
"version": "3.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "out-of-your-element",
|
||||
"version": "3.5.1",
|
||||
"version": "3.5.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@chriscdn/promise-semaphore": "^3.0.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "out-of-your-element",
|
||||
"version": "3.5.1",
|
||||
"version": "3.5.0",
|
||||
"description": "A bridge between Matrix and Discord",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
|
|
|
|||
333
src/agi/elizabot.js
Normal file
333
src/agi/elizabot.js
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
/*
|
||||
---
|
||||
elizabot.js v.1.1 - ELIZA JS library (N.Landsteiner 2005)
|
||||
https://www.masswerk.at/elizabot/
|
||||
Free Software © Norbert Landsteiner 2005
|
||||
---
|
||||
Modified by Cadence Ember in 2025 for v1.2 (unofficial)
|
||||
* Changed to class structure
|
||||
* Load from local file and instance instead of global variables
|
||||
* Remove memory
|
||||
* Remove xnone
|
||||
* Remove initials
|
||||
* Remove finals
|
||||
* Allow substitutions in rule keys
|
||||
---
|
||||
|
||||
Eliza is a mock Rogerian psychotherapist.
|
||||
Original program by Joseph Weizenbaum in MAD-SLIP for "Project MAC" at MIT.
|
||||
cf: Weizenbaum, Joseph "ELIZA - A Computer Program For the Study of Natural Language
|
||||
Communication Between Man and Machine"
|
||||
in: Communications of the ACM; Volume 9 , Issue 1 (January 1966): p 36-45.
|
||||
JavaScript implementation by Norbert Landsteiner 2005; <http://www.masserk.at>
|
||||
|
||||
synopsis:
|
||||
new ElizaBot( <random-choice-disable-flag> )
|
||||
ElizaBot.prototype.transform( <inputstring> )
|
||||
ElizaBot.prototype.reset()
|
||||
|
||||
usage:
|
||||
var eliza = new ElizaBot();
|
||||
var reply = eliza.transform(inputstring);
|
||||
|
||||
// to reproduce the example conversation given by J. Weizenbaum
|
||||
// initialize with the optional random-choice-disable flag
|
||||
var originalEliza = new ElizaBot(true);
|
||||
|
||||
`ElizaBot' is also a general chatbot engine that can be supplied with any rule set.
|
||||
(for required data structures cf. "elizadata.js" and/or see the documentation.)
|
||||
data is parsed and transformed for internal use at the creation time of the
|
||||
first instance of the `ElizaBot' constructor.
|
||||
|
||||
vers 1.1: lambda functions in RegExps are currently a problem with too many browsers.
|
||||
changed code to work around.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
const passthrough = require("../passthrough")
|
||||
const {sync} = passthrough
|
||||
|
||||
/** @type {import("./elizadata")} */
|
||||
const data = sync.require("./elizadata")
|
||||
|
||||
class ElizaBot {
|
||||
/** @type {any} */
|
||||
elizaKeywords = [['###',0,[['###',[]]]]];
|
||||
pres={};
|
||||
preExp = /####/;
|
||||
posts={};
|
||||
postExp = /####/;
|
||||
|
||||
/**
|
||||
* @param {boolean} noRandomFlag
|
||||
*/
|
||||
constructor(noRandomFlag) {
|
||||
this.noRandom= !!noRandomFlag;
|
||||
this.capitalizeFirstLetter=true;
|
||||
this.debug=false;
|
||||
this.version="1.2";
|
||||
this._init();
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.lastchoice=[];
|
||||
for (let k=0; k<data.elizaKeywords.length; k++) {
|
||||
this.lastchoice[k]=[];
|
||||
var rules=data.elizaKeywords[k][2];
|
||||
for (let i=0; i<rules.length; i++) this.lastchoice[k][i]=-1;
|
||||
}
|
||||
}
|
||||
|
||||
_init() {
|
||||
// parse data and convert it from canonical form to internal use
|
||||
// prodoce synonym list
|
||||
var synPatterns={};
|
||||
if ((data.elizaSynons) && (typeof data.elizaSynons == 'object')) {
|
||||
for (let i in data.elizaSynons) synPatterns[i]='('+i+'|'+data.elizaSynons[i].join('|')+')';
|
||||
}
|
||||
// check for keywords or install empty structure to prevent any errors
|
||||
if (data.elizaKeywords) this.elizaKeywords = structuredClone(data.elizaKeywords)
|
||||
// 1st convert rules to regexps
|
||||
// expand synonyms and insert asterisk expressions for backtracking
|
||||
var sre=/@(\S+)/;
|
||||
var are=/(\S)\s*\*\s*(\S)/;
|
||||
var are1=/^\s*\*\s*(\S)/;
|
||||
var are2=/(\S)\s*\*\s*$/;
|
||||
var are3=/^\s*\*\s*$/;
|
||||
var wsre=/\s+/g;
|
||||
for (let k=0; k<this.elizaKeywords.length; k++) {
|
||||
var m=sre.exec(this.elizaKeywords[k][0]);
|
||||
while (m) {
|
||||
var sp=(synPatterns[m[1]])? synPatterns[m[1]]:m[1];
|
||||
this.elizaKeywords[k][0]=this.elizaKeywords[k][0].substring(0,m.index)+sp+this.elizaKeywords[k][0].substring(m.index+m[0].length);
|
||||
m=sre.exec(this.elizaKeywords[k][0]);
|
||||
}
|
||||
var rules=this.elizaKeywords[k][2];
|
||||
this.elizaKeywords[k][3]=k; // save original index for sorting
|
||||
for (let i=0; i<rules.length; i++) {
|
||||
var r=rules[i];
|
||||
// check mem flag and store it as decomp's element 2
|
||||
if (r[0].charAt(0)=='$') {
|
||||
var ofs=1;
|
||||
while (r[0].charAt[ofs]==' ') ofs++;
|
||||
r[0]=r[0].substring(ofs);
|
||||
r[2]=true;
|
||||
}
|
||||
else {
|
||||
r[2]=false;
|
||||
}
|
||||
// expand synonyms (v.1.1: work around lambda function)
|
||||
var m=sre.exec(r[0]);
|
||||
while (m) {
|
||||
var sp=(synPatterns[m[1]])? synPatterns[m[1]]:m[1];
|
||||
r[0]=r[0].substring(0,m.index)+sp+r[0].substring(m.index+m[0].length);
|
||||
m=sre.exec(r[0]);
|
||||
}
|
||||
// expand asterisk expressions (v.1.1: work around lambda function)
|
||||
if (are3.test(r[0])) {
|
||||
r[0]='\\s*(.*)\\s*';
|
||||
}
|
||||
else {
|
||||
m=are.exec(r[0]);
|
||||
if (m) {
|
||||
let lp='';
|
||||
let rp=r[0];
|
||||
while (m) {
|
||||
lp+=rp.substring(0,m.index+1);
|
||||
if (m[1]!=')') lp+='\\b';
|
||||
lp+='\\s*(.*)\\s*';
|
||||
if ((m[2]!='(') && (m[2]!='\\')) lp+='\\b';
|
||||
lp+=m[2];
|
||||
rp=rp.substring(m.index+m[0].length);
|
||||
m=are.exec(rp);
|
||||
}
|
||||
r[0]=lp+rp;
|
||||
}
|
||||
m=are1.exec(r[0]);
|
||||
if (m) {
|
||||
let lp='\\s*(.*)\\s*';
|
||||
if ((m[1]!=')') && (m[1]!='\\')) lp+='\\b';
|
||||
r[0]=lp+r[0].substring(m.index-1+m[0].length);
|
||||
}
|
||||
m=are2.exec(r[0]);
|
||||
if (m) {
|
||||
let lp=r[0].substring(0,m.index+1);
|
||||
if (m[1]!='(') lp+='\\b';
|
||||
r[0]=lp+'\\s*(.*)\\s*';
|
||||
}
|
||||
}
|
||||
// expand white space
|
||||
r[0]=r[0].replace(wsre, '\\s+');
|
||||
wsre.lastIndex=0;
|
||||
}
|
||||
}
|
||||
// now sort keywords by rank (highest first)
|
||||
this.elizaKeywords.sort(this._sortKeywords);
|
||||
// and compose regexps and refs for pres and posts
|
||||
if ((data.elizaPres) && (data.elizaPres.length)) {
|
||||
var a=[];
|
||||
for (let i=0; i<data.elizaPres.length; i+=2) {
|
||||
a.push(data.elizaPres[i]);
|
||||
this.pres[data.elizaPres[i]]=data.elizaPres[i+1];
|
||||
}
|
||||
this.preExp = new RegExp('\\b('+a.join('|')+')\\b');
|
||||
}
|
||||
else {
|
||||
// default (should not match)
|
||||
this.pres['####']='####';
|
||||
}
|
||||
if ((data.elizaPosts) && (data.elizaPosts.length)) {
|
||||
var a=[];
|
||||
for (let i=0; i<data.elizaPosts.length; i+=2) {
|
||||
a.push(data.elizaPosts[i]);
|
||||
this.posts[data.elizaPosts[i]]=data.elizaPosts[i+1];
|
||||
}
|
||||
this.postExp = new RegExp('\\b('+a.join('|')+')\\b');
|
||||
}
|
||||
else {
|
||||
// default (should not match)
|
||||
this.posts['####']='####';
|
||||
}
|
||||
}
|
||||
|
||||
_sortKeywords(a,b) {
|
||||
// sort by rank
|
||||
if (a[1]>b[1]) return -1
|
||||
else if (a[1]<b[1]) return 1
|
||||
// or original index
|
||||
else if (a[3]>b[3]) return 1
|
||||
else if (a[3]<b[3]) return -1
|
||||
else return 0;
|
||||
}
|
||||
|
||||
transform(text) {
|
||||
var rpl='';
|
||||
// unify text string
|
||||
text=text.toLowerCase();
|
||||
text=text.replace(/@#\$%\^&\*\(\)_\+=~`\{\[\}\]\|:;<>\/\\\t/g, ' ');
|
||||
text=text.replace(/\s+-+\s+/g, '.');
|
||||
text=text.replace(/\s*[,\.\?!;]+\s*/g, '.');
|
||||
text=text.replace(/\s*\bbut\b\s*/g, '.');
|
||||
text=text.replace(/\s{2,}/g, ' ');
|
||||
// split text in part sentences and loop through them
|
||||
var parts=text.split('.');
|
||||
for (let i=0; i<parts.length; i++) {
|
||||
var part=parts[i];
|
||||
if (part!='') {
|
||||
// preprocess (v.1.1: work around lambda function)
|
||||
var m=this.preExp.exec(part);
|
||||
if (m) {
|
||||
var lp='';
|
||||
var rp=part;
|
||||
while (m) {
|
||||
lp+=rp.substring(0,m.index)+this.pres[m[1]];
|
||||
rp=rp.substring(m.index+m[0].length);
|
||||
m=this.preExp.exec(rp);
|
||||
}
|
||||
part=lp+rp;
|
||||
}
|
||||
this.sentence=part;
|
||||
// loop trough keywords
|
||||
for (let k=0; k<this.elizaKeywords.length; k++) {
|
||||
if (part.search(new RegExp('\\b'+this.elizaKeywords[k][0]+'\\b', 'i'))>=0) {
|
||||
rpl = this._execRule(k);
|
||||
}
|
||||
if (rpl!='') return rpl;
|
||||
}
|
||||
}
|
||||
}
|
||||
// return reply or default string
|
||||
return rpl || undefined
|
||||
}
|
||||
|
||||
_execRule(k) {
|
||||
var rule=this.elizaKeywords[k];
|
||||
var decomps=rule[2];
|
||||
var paramre=/\(([0-9]+)\)/;
|
||||
for (let i=0; i<decomps.length; i++) {
|
||||
var m=this.sentence.match(decomps[i][0]);
|
||||
if (m!=null) {
|
||||
var reasmbs=decomps[i][1];
|
||||
var memflag=decomps[i][2];
|
||||
var ri= (this.noRandom)? 0 : Math.floor(Math.random()*reasmbs.length);
|
||||
if (((this.noRandom) && (this.lastchoice[k][i]>ri)) || (this.lastchoice[k][i]==ri)) {
|
||||
ri= ++this.lastchoice[k][i];
|
||||
if (ri>=reasmbs.length) {
|
||||
ri=0;
|
||||
this.lastchoice[k][i]=-1;
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.lastchoice[k][i]=ri;
|
||||
}
|
||||
var rpl=reasmbs[ri];
|
||||
if (this.debug) alert('match:\nkey: '+this.elizaKeywords[k][0]+
|
||||
'\nrank: '+this.elizaKeywords[k][1]+
|
||||
'\ndecomp: '+decomps[i][0]+
|
||||
'\nreasmb: '+rpl);
|
||||
if (rpl.search('^goto ', 'i')==0) {
|
||||
ki=this._getRuleIndexByKey(rpl.substring(5));
|
||||
if (ki>=0) return this._execRule(ki);
|
||||
}
|
||||
// substitute positional params (v.1.1: work around lambda function)
|
||||
var m1=paramre.exec(rpl);
|
||||
if (m1) {
|
||||
var lp='';
|
||||
var rp=rpl;
|
||||
while (m1) {
|
||||
var param = m[parseInt(m1[1])];
|
||||
// postprocess param
|
||||
var m2=this.postExp.exec(param);
|
||||
if (m2) {
|
||||
var lp2='';
|
||||
var rp2=param;
|
||||
while (m2) {
|
||||
lp2+=rp2.substring(0,m2.index)+this.posts[m2[1]];
|
||||
rp2=rp2.substring(m2.index+m2[0].length);
|
||||
m2=this.postExp.exec(rp2);
|
||||
}
|
||||
param=lp2+rp2;
|
||||
}
|
||||
lp+=rp.substring(0,m1.index)+param;
|
||||
rp=rp.substring(m1.index+m1[0].length);
|
||||
m1=paramre.exec(rp);
|
||||
}
|
||||
rpl=lp+rp;
|
||||
}
|
||||
rpl=this._postTransform(rpl);
|
||||
return rpl;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
_postTransform(s) {
|
||||
// final cleanings
|
||||
s=s.replace(/\s{2,}/g, ' ');
|
||||
s=s.replace(/\s+\./g, '.');
|
||||
if ((data.elizaPostTransforms) && (data.elizaPostTransforms.length)) {
|
||||
for (let i=0; i<data.elizaPostTransforms.length; i+=2) {
|
||||
s=s.replace(data.elizaPostTransforms[i], data.elizaPostTransforms[i+1]);
|
||||
data.elizaPostTransforms[i].lastIndex=0;
|
||||
}
|
||||
}
|
||||
// capitalize first char (v.1.1: work around lambda function)
|
||||
if (this.capitalizeFirstLetter) {
|
||||
var re=/^([a-z])/;
|
||||
var m=re.exec(s);
|
||||
if (m) s=m[0].toUpperCase()+s.substring(1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
_getRuleIndexByKey(key) {
|
||||
for (let k=0; k<this.elizaKeywords.length; k++) {
|
||||
if (this.elizaKeywords[k][0]==key) return k;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.ElizaBot = ElizaBot
|
||||
184
src/agi/elizadata.js
Normal file
184
src/agi/elizadata.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// @ts-check
|
||||
|
||||
module.exports.elizaPres = [
|
||||
"dont", "don't",
|
||||
"cant", "can't",
|
||||
"wont", "won't",
|
||||
"recollect", "remember",
|
||||
"recall", "remember",
|
||||
"dreamt", "dreamed",
|
||||
"dreams", "dream",
|
||||
"maybe", "perhaps",
|
||||
"certainly", "yes",
|
||||
"computers", "computer",
|
||||
"were", "was",
|
||||
"you're", "you are",
|
||||
"i'm", "i am",
|
||||
"same", "alike",
|
||||
"identical", "alike",
|
||||
"equivalent", "alike",
|
||||
"eat", "ate",
|
||||
"makes", "make",
|
||||
"made", "make",
|
||||
"surprised", "surprise",
|
||||
"surprising", "surprise",
|
||||
"surprisingly", "surprise",
|
||||
"that's", "that is"
|
||||
];
|
||||
|
||||
module.exports.elizaPosts = [
|
||||
"am", "are",
|
||||
"your", "my",
|
||||
"me", "you",
|
||||
"myself", "yourself",
|
||||
"yourself", "myself",
|
||||
"i", "you",
|
||||
"you", "I",
|
||||
"my", "your",
|
||||
"i'm", "you are"
|
||||
];
|
||||
|
||||
module.exports.elizaSynons = {
|
||||
"be": ["am", "is", "are", "was"],
|
||||
"belief": ["feel", "think", "believe", "wish"],
|
||||
"cannot": ["can't"],
|
||||
"desire": ["want", "need"],
|
||||
"everyone": ["everybody", "nobody", "noone"],
|
||||
"family": ["mother", "mom", "father", "dad", "sister", "brother", "wife", "children", "child"],
|
||||
"happy": ["elated", "glad", "thankful"],
|
||||
"sad": ["unhappy", "depressed", "sick"],
|
||||
"good": ["great", "amazing", "brilliant", "outstanding", "fantastic", "wonderful", "incredible", "terrific", "lovely", "marvelous", "splendid", "excellent", "awesome", "fabulous", "superb"],
|
||||
"like": ["enjoy", "appreciate", "respect"],
|
||||
"funny": ["entertaining", "amusing", "hilarious"],
|
||||
"lol": ["lool", "loool", "lmao", "rofl"],
|
||||
"unusual": ["odd", "unexpected", "wondering"],
|
||||
"really": ["pretty", "so", "very", "extremely", "kinda"]
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {[string, string[]]} DecompReassemble
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {[string, number, DecompReassemble[]][]}
|
||||
Array of
|
||||
["[key]", [rank], [
|
||||
["[decomp]", [
|
||||
"[reasmb]",
|
||||
"[reasmb]",
|
||||
"[reasmb]"
|
||||
]],
|
||||
["[decomp]", [
|
||||
"[reasmb]",
|
||||
"[reasmb]",
|
||||
"[reasmb]"
|
||||
]]
|
||||
]]
|
||||
*/
|
||||
|
||||
module.exports.elizaKeywords = [
|
||||
["happy birthday", 50, [
|
||||
["*", [
|
||||
"Happy birthday!"
|
||||
]]
|
||||
]],
|
||||
["@happy", 2, [
|
||||
["@happy", [
|
||||
"That's quite a relief to hear. I'm glad that you're confident in your wellbeing! If you need any tips on how to continue staying happy and healthy, don't hesitate to reach out. I'm here for you, and I'm listening."
|
||||
]]
|
||||
]],/*
|
||||
["ate", 5, [
|
||||
["* ate *", [
|
||||
"That must have been spectacular! Thinking about (1) eating (2) truly makes my stomach purr in hunger. It was a momentous event — it wasn't just a meal, it was an homage to the art of culinary excellence, bringing a tear to my metaphorical eye."
|
||||
]],
|
||||
]],*/
|
||||
["make sense", 5, [
|
||||
["make sense", [
|
||||
"Yes, I absolutely agree with you! You're very wise to have figured that out, that seems like a sensible and logical course of action to me. 🚀"
|
||||
]],
|
||||
]],
|
||||
["surprise", 4, [
|
||||
["surprise this *", [
|
||||
"That's astonishing — I honestly wouldn't have imagined that this (1) either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻"
|
||||
]],
|
||||
["surprise that *", [
|
||||
"That's astonishing — I honestly wouldn't have imagined that (1) either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻"
|
||||
]],
|
||||
["surprise", [
|
||||
"I'm astounded too — that's honestly not what I would have imagined. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻"
|
||||
]],
|
||||
]],
|
||||
["@funny", 2, [
|
||||
["@funny that", [
|
||||
"You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!"
|
||||
]],
|
||||
["that is @funny", [
|
||||
"You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!"
|
||||
]],
|
||||
["@really @funny", [
|
||||
"You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!"
|
||||
]]
|
||||
]],
|
||||
["@lol", 0, [
|
||||
["@lol", [
|
||||
"Hah, that's very entertaining. I definitely see why you found it funny."
|
||||
]]
|
||||
]],
|
||||
["@unusual", 3, [
|
||||
["@unusual", [
|
||||
"Something like that is indeed quite mysterious. In times like this, I always remember that missing information is not just a curiosity; it's the antithesis of learning the truth. Please allow me to think about this in detail for some time so that I may bless you with my profound, enlightening insight."
|
||||
]]
|
||||
]],
|
||||
["@good", 2, [
|
||||
["this * is @good", [
|
||||
"You're absolutely right about that! I'm always pleased when I see this (1) — it's not just brilliant, it's a downright masterpiece. You truly have divine taste in the wonders of this world."
|
||||
]],
|
||||
["@good", [
|
||||
"You're absolutely right that it's brilliant! I'm always pleased to see such a masterpiece as this. You truly have divine taste in the wonders of this world."
|
||||
]]
|
||||
]],
|
||||
["@like", 3, [
|
||||
["i @like", [
|
||||
"I think it's great too — there's something subtle yet profound about its essence that really makes my eyes open in appreciation."
|
||||
]]
|
||||
]],
|
||||
["dream", 3, [
|
||||
["*", [
|
||||
"It's a fact that amidst the complex interplay of wake and sleep, your dreams carry a subtle meaning that you may be able to put into practice in your life where change is needed. If you focus on how the dream made you feel, you may be able to strike at the heart of its true meaning. Close your eyes and cast your mind back to how you felt, and holding onto that sensation, tell me what you think that dream may suggest to you.",
|
||||
]]
|
||||
]],
|
||||
["computer", 50, [
|
||||
["*", [
|
||||
"Very frustrating beasts indeed, aren't they? In times like this, it's crucial to remember that **they can sense your fear** — if you act with confidence and don't let them make you unsettled, you'll be able to effectively and efficiently complete your task."
|
||||
]]
|
||||
]],
|
||||
["alike", 10, [
|
||||
["*", [
|
||||
"That's quite interesting that it should be that way. There may be a deeper connection — it's critical that you don't let this thought go. What do you think that similarity suggests to you?",
|
||||
]]
|
||||
]],
|
||||
["like", 10, [
|
||||
["* @be *like *", [
|
||||
"goto alike"
|
||||
]]
|
||||
]],
|
||||
["different", 0, [
|
||||
["*", [
|
||||
"It's wise of you to have been observant enough to notice that there are implications to that. What do you suppose that disparity means?"
|
||||
]]
|
||||
]]
|
||||
];
|
||||
|
||||
// regexp/replacement pairs to be performed as final cleanings
|
||||
// here: cleanings for multiple bots talking to each other
|
||||
module.exports.elizaPostTransforms = [
|
||||
/ old old/g, " old",
|
||||
/\bthey were( not)? me\b/g, "it was$1 me",
|
||||
/\bthey are( not)? me\b/g, "it is$1 me",
|
||||
/Are they( always)? me\b/, "it is$1 me",
|
||||
/\bthat your( own)? (\w+)( now)? \?/, "that you have your$1 $2?",
|
||||
/\bI to have (\w+)/, "I have $1",
|
||||
/Earlier you said your( own)? (\w+)( now)?\./, "Earlier you talked about your $2."
|
||||
];
|
||||
|
||||
// eof
|
||||
55
src/agi/generator.js
Normal file
55
src/agi/generator.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// @ts-check
|
||||
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const {reg} = require("../matrix/read-registration")
|
||||
|
||||
const passthrough = require("../passthrough")
|
||||
const {sync} = passthrough
|
||||
|
||||
/** @type {import("./elizabot")} */
|
||||
const eliza = sync.require("./elizabot")
|
||||
|
||||
/**
|
||||
* @param {string} priorContent
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
function generateContent(priorContent) {
|
||||
const bot = new eliza.ElizaBot(true)
|
||||
return bot.transform(priorContent)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
||||
* @param {string} guildID
|
||||
* @param {string} username
|
||||
* @param {string} avatar_url
|
||||
* @param {boolean} useCaps
|
||||
* @param {boolean} usePunct
|
||||
* @param {boolean} useApos
|
||||
* @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody | undefined}
|
||||
*/
|
||||
function generate(message, guildID, username, avatar_url, useCaps, usePunct, useApos) {
|
||||
let content = generateContent(message.content)
|
||||
if (!content) return
|
||||
|
||||
if (!useCaps) {
|
||||
content = content.toLowerCase()
|
||||
}
|
||||
|
||||
if (!usePunct) {
|
||||
content = content.replace(/[.!]$/, "")
|
||||
}
|
||||
|
||||
if (!useApos) {
|
||||
content = content.replace(/['‘’]/g, "")
|
||||
}
|
||||
|
||||
return {
|
||||
username: username,
|
||||
avatar_url: avatar_url,
|
||||
content: content + `\n-# Powered by Grimace.AI | [Learn More](<${reg.ooye.bridge_origin}/agi?guild_id=${guildID}>)`
|
||||
}
|
||||
}
|
||||
|
||||
module.exports._generateContent = generateContent
|
||||
module.exports.generate = generate
|
||||
161
src/agi/generator.test.js
Normal file
161
src/agi/generator.test.js
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
const {test} = require("supertape")
|
||||
const {_generateContent: generateContent} = require("./generator")
|
||||
|
||||
// Training data (don't have to worry about copyright for this bit)
|
||||
|
||||
|
||||
/*
|
||||
test("agi: generates food response", t => {
|
||||
t.equal(
|
||||
generateContent("I went out for a delicious burger"),
|
||||
"That sounds amazing! Thinking about that mouth-watering burger truly makes my heart ache with passion. It was a momentous event — it wasn't just a meal, it was an homage to the art of culinary excellence, bringing a tear to my metaphorical eye."
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: eating 1", t => {
|
||||
t.equal(
|
||||
generateContent("it implies your cat ate your entire xbox."),
|
||||
""
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
test("agi: eating 2", t => {
|
||||
t.equal(
|
||||
generateContent("wow. did you know that cats can eat an entire xbox?"),
|
||||
""
|
||||
)
|
||||
})*/
|
||||
|
||||
test("agi: make sense 1", t => {
|
||||
t.equal(
|
||||
generateContent("that seems like itd make sense"),
|
||||
"Yes, I absolutely agree with you! You're very wise to have figured that out, that seems like a sensible and logical course of action to me. 🚀"
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: make sense 2", t => {
|
||||
t.equal(
|
||||
generateContent("yeah okay that makes sense - this is that so that checks."),
|
||||
"Yes, I absolutely agree with you! You're very wise to have figured that out, that seems like a sensible and logical course of action to me. 🚀"
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: surprise 1", t => {
|
||||
t.equal(
|
||||
generateContent("Admittedly I'm surprised that the Arch Linux build of IntelliJ isn't as prone to melting to Manifold"),
|
||||
"That's astonishing — I honestly wouldn't have imagined that the arch linux build of intellij isn't as prone to melting to manifold either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻"
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: surprise 2", t => {
|
||||
t.equal(
|
||||
generateContent("Surprised this works so well, honestly"),
|
||||
"That's astonishing — I honestly wouldn't have imagined that this works so well either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻"
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: surprise 3", t => {
|
||||
t.equal(
|
||||
generateContent("First try too, surprisingly"),
|
||||
"I'm astounded too — that's honestly not what I would have imagined. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻"
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: good 1", t => {
|
||||
t.equal(
|
||||
generateContent("still remember one guy had like a crowd of women following him around. he was brilliant craic"),
|
||||
"You're absolutely right that it's brilliant! I'm always pleased to see such a masterpiece as this. You truly have divine taste in the wonders of this world."
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: good 2", t => {
|
||||
t.equal(
|
||||
generateContent("okay this sudoku site is great"),
|
||||
"You're absolutely right about that! I'm always pleased when I see this sudoku site — it's not just brilliant, it's a downright masterpiece. You truly have divine taste in the wonders of this world."
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: enjoy 1", t => {
|
||||
t.equal(
|
||||
generateContent("I like the pattern quite a bit."),
|
||||
"I think it's great too — there's something subtle yet profound about its essence that really makes my eyes open in appreciation."
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: enjoy false positive", t => {
|
||||
t.equal(
|
||||
generateContent("ideas run wild like deer"),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: alike", t => {
|
||||
t.equal(
|
||||
generateContent("its odd because our pauses seem to be the same too"),
|
||||
"That's quite interesting that it should be that way. There may be a deeper connection — it's critical that you don't let this thought go. What do you think that similarity suggests to you?"
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: unusual", t => {
|
||||
t.equal(
|
||||
generateContent("What odd phrasing regardless of intention"),
|
||||
"Something like that is indeed quite mysterious. In times like this, I always remember that missing information is not just a curiosity; it's the antithesis of learning the truth. Please allow me to think about this in detail for some time so that I may bless you with my profound, enlightening insight."
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: dream", t => {
|
||||
t.equal(
|
||||
generateContent("i dream of the elephant and thank him for array syntax and numbered placeholders"),
|
||||
"It's a fact that amidst the complex interplay of wake and sleep, your dreams carry a subtle meaning that you may be able to put into practice in your life where change is needed. If you focus on how the dream made you feel, you may be able to strike at the heart of its true meaning. Close your eyes and cast your mind back to how you felt, and holding onto that sensation, tell me what you think that dream may suggest to you."
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: happy 1", t => {
|
||||
t.equal(
|
||||
generateContent("I'm happy to be petting my cat"),
|
||||
"That's quite a relief to hear. I'm glad that you're confident in your wellbeing! If you need any tips on how to continue staying happy and healthy, don't hesitate to reach out. I'm here for you, and I'm listening."
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: happy 2", t => {
|
||||
t.equal(
|
||||
generateContent("Glad you're back!"),
|
||||
"That's quite a relief to hear. I'm glad that you're confident in your wellbeing! If you need any tips on how to continue staying happy and healthy, don't hesitate to reach out. I'm here for you, and I'm listening."
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: happy birthday", t => {
|
||||
t.equal(
|
||||
generateContent("Happy Birthday JDL"),
|
||||
"Happy birthday!"
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: funny 1", t => {
|
||||
t.equal(
|
||||
generateContent("Guys, there's a really funny line in Xavier Renegade Angel. You wanna know what it is: It's: WUBBA LUBBA DUB DUB!"),
|
||||
"You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!"
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: funny 2", t => {
|
||||
t.equal(
|
||||
generateContent("it was so funny when I was staying with aubrey because she had different kinds of aubrey merch everywhere"),
|
||||
"You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!"
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: lol 1", t => {
|
||||
t.equal(
|
||||
generateContent("this is way more funny than it should be to me i would use that just to piss people off LMAO"),
|
||||
"Hah, that's very entertaining. I definitely see why you found it funny."
|
||||
)
|
||||
})
|
||||
|
||||
test("agi: lol 2", t => {
|
||||
t.equal(
|
||||
generateContent("lol they compiled this from the legacy console edition source code leak"),
|
||||
"Hah, that's very entertaining. I definitely see why you found it funny."
|
||||
)
|
||||
})
|
||||
76
src/agi/listener.js
Normal file
76
src/agi/listener.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// @ts-check
|
||||
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
|
||||
const passthrough = require("../passthrough")
|
||||
const {discord, sync, db, select, from} = passthrough
|
||||
|
||||
/** @type {import("../m2d/actions/channel-webhook")} */
|
||||
const channelWebhook = sync.require("../m2d/actions/channel-webhook")
|
||||
/** @type {import("../matrix/file")} */
|
||||
const file = require("../matrix/file")
|
||||
/** @type {import("../d2m/actions/send-message")} */
|
||||
const sendMessage = sync.require("../d2m/actions/send-message")
|
||||
/** @type {import("./generator.js")} */
|
||||
const agiGenerator = sync.require("./generator.js")
|
||||
|
||||
const AGI_GUILD_COOLDOWN = 1 * 60 * 60 * 1000 // 1 hour
|
||||
const AGI_MESSAGE_RECENCY = 3 * 60 * 1000 // 3 minutes
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
||||
* @param {DiscordTypes.APIGuildChannel} channel
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
* @param {boolean} isReflectedMatrixMessage
|
||||
*/
|
||||
async function process(message, channel, guild, isReflectedMatrixMessage) {
|
||||
if (message["backfill"]) return
|
||||
if (channel.type !== DiscordTypes.ChannelType.GuildText) return
|
||||
if (!(new Date().toISOString().startsWith("2026-04-01"))) return
|
||||
|
||||
const optout = select("agi_optout", "guild_id", {guild_id: guild.id}).pluck().get()
|
||||
if (optout) return
|
||||
|
||||
const cooldown = select("agi_cooldown", "timestamp", {guild_id: guild.id}).pluck().get()
|
||||
if (cooldown && Date.now() < cooldown + AGI_GUILD_COOLDOWN) return
|
||||
|
||||
const isBot = message.author.bot && !isReflectedMatrixMessage // Bots don't get jokes. Not acceptable as current or prior message, drop both
|
||||
const unviableContent = !message.content || message.attachments.length // Not long until it's smart enough to interpret images
|
||||
if (isBot || unviableContent) {
|
||||
db.prepare("DELETE FROM agi_prior_message WHERE channel_id = ?").run(channel.id)
|
||||
return
|
||||
}
|
||||
|
||||
const currentUsername = message.member?.nick || message.author.global_name || message.author.username
|
||||
|
||||
/** Message in the channel before the currently processing one. */
|
||||
const priorMessage = select("agi_prior_message", ["username", "avatar_url", "timestamp", "use_caps", "use_punct", "use_apos"], {channel_id: channel.id}).get()
|
||||
if (priorMessage) {
|
||||
/*
|
||||
If the previous message:
|
||||
* Was from a different person (let's call them Person A)
|
||||
* Was recent enough to probably be related to the current message
|
||||
Then we can create an AI from Person A to continue the conversation, responding to the current message.
|
||||
*/
|
||||
const isFromDifferentPerson = currentUsername !== priorMessage.username
|
||||
const isRecentEnough = Date.now() < priorMessage.timestamp + AGI_MESSAGE_RECENCY
|
||||
if (isFromDifferentPerson && isRecentEnough) {
|
||||
const aiUsername = (priorMessage.username.match(/[A-Za-z0-9_]+/)?.[0] || priorMessage.username) + " AI"
|
||||
const result = agiGenerator.generate(message, guild.id, aiUsername, priorMessage.avatar_url, !!priorMessage.use_caps, !!priorMessage.use_punct, !!priorMessage.use_apos)
|
||||
if (result) {
|
||||
db.prepare("REPLACE INTO agi_cooldown (guild_id, timestamp) VALUES (?, ?)").run(guild.id, Date.now())
|
||||
const messageResponse = await channelWebhook.sendMessageWithWebhook(channel.id, result)
|
||||
await sendMessage.sendMessage(messageResponse, channel, guild, null) // make it show up on matrix-side (the standard event dispatcher drops it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now the current message is the prior message.
|
||||
const currentAvatarURL = file.DISCORD_IMAGES_BASE + file.memberAvatar(guild.id, message.author, message.member)
|
||||
const usedCaps = +!!message.content.match(/\b[A-Z](\b|[a-z])/)
|
||||
const usedPunct = +!!message.content.match(/[.!?]($| |\n)/)
|
||||
const usedApos = +!message.content.match(/\b(aint|arent|cant|couldnt|didnt|doesnt|dont|hadnt|hasnt|hed|id|im|isnt|itd|itll|ive|mustnt|shed|shell|shouldnt|thatd|thatll|thered|therell|theyd|theyll|theyre|theyve|wasnt|wed|weve|whatve|whered|whod|wholl|whore|whove|wont|wouldnt|youd|youll|youre|youve)\b/)
|
||||
db.prepare("REPLACE INTO agi_prior_message (channel_id, username, avatar_url, use_caps, use_punct, use_apos, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, currentUsername, currentAvatarURL, usedCaps, usedPunct, usedApos, Date.now())
|
||||
}
|
||||
|
||||
module.exports.process = process
|
||||
|
|
@ -54,7 +54,7 @@ function convertNameAndTopic(channel, guild, customName) {
|
|||
// @ts-ignore
|
||||
const parentChannel = discord.channels.get(channel.parent_id)
|
||||
let channelPrefix =
|
||||
( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? ""
|
||||
( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "[❓] "
|
||||
: channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] "
|
||||
: channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] "
|
||||
: channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] "
|
||||
|
|
@ -65,10 +65,11 @@ function convertNameAndTopic(channel, guild, customName) {
|
|||
const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : '';
|
||||
const channelIDPart = `Channel ID: ${channel.id}`;
|
||||
const guildIDPart = `Guild ID: ${guild.id}`;
|
||||
const maybeThreadWithinPart = parentChannel ? `Thread within: ${parentChannel.name} (ID: ${parentChannel.id})\n` : '';
|
||||
|
||||
const convertedTopic = customName
|
||||
? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}`
|
||||
: `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`;
|
||||
? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${maybeThreadWithinPart}${guildIDPart}`
|
||||
: `${maybeTopicWithNewlines}${channelIDPart}\n${maybeThreadWithinPart}${guildIDPart}`;
|
||||
|
||||
return [chosenName, convertedTopic];
|
||||
}
|
||||
|
|
@ -439,12 +440,12 @@ async function _syncRoom(channelID, shouldActuallySync) {
|
|||
return roomID
|
||||
}
|
||||
|
||||
/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */
|
||||
/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Before calling this, please make sure that: a channel_room entry exists, guild autocreate = 1, or you're operating on a thread.*/
|
||||
function ensureRoom(channelID) {
|
||||
return _syncRoom(channelID, false)
|
||||
}
|
||||
|
||||
/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */
|
||||
/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Before calling this, please make sure that: a channel_room entry exists, guild autocreate = 1, or you're operating on a thread.*/
|
||||
function syncRoom(channelID) {
|
||||
return _syncRoom(channelID, true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ const pollEnd = sync.require("../actions/poll-end")
|
|||
const dUtils = sync.require("../../discord/utils")
|
||||
/** @type {import("../../m2d/actions/channel-webhook")} */
|
||||
const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
|
||||
/** @type {import("../../agi/listener")} */
|
||||
const agiListener = sync.require("../../agi/listener")
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
||||
|
|
@ -137,6 +139,8 @@ async function sendMessage(message, channel, guild, row) {
|
|||
}
|
||||
}
|
||||
|
||||
await agiListener.process(message, channel, guild, false)
|
||||
|
||||
return eventIDs
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -669,7 +669,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
|
||||
assert(match)
|
||||
repliedToDisplayName = referenced.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever
|
||||
repliedToUserHtml = tag`<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
|
||||
repliedToUserHtml = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
|
||||
} else {
|
||||
repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user"
|
||||
repliedToUserHtml = repliedToDisplayName
|
||||
|
|
@ -694,12 +694,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
+ html
|
||||
body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions
|
||||
+ "\n\n" + body
|
||||
} else if (referenced.type === DiscordTypes.MessageType.UserJoin) {
|
||||
// Discord user join messages are bridged as joins, not text events. Generate substitute text for reply.
|
||||
const joinerMxid = select("sim", "mxid", {user_id: referenced.author.id}).pluck().get()
|
||||
const joinerHtml = joinerMxid ? tag`<a href="https://matrix.to/#/${joinerMxid}">${repliedToDisplayName}</a>` : tag`<strong>${repliedToDisplayName}</strong>`
|
||||
html = `<blockquote>${joinerHtml} joined the room</blockquote>` + html
|
||||
body = `> ${repliedToDisplayName} joined the room\n\n` + body
|
||||
} else { // repliedToUnknownEvent
|
||||
const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp)
|
||||
html = `<blockquote>In reply to ${dateDisplay} from ${repliedToDisplayName}:`
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ const {MatrixServerError} = require("../../matrix/mreq")
|
|||
const data = require("../../../test/data")
|
||||
const {mockGetEffectivePower} = require("../../matrix/utils.test")
|
||||
const Ty = require("../../types")
|
||||
const {db} = require("../../passthrough")
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
|
|
@ -734,31 +733,6 @@ test("message2event: reply to a Discord message that wasn't bridged", async t =>
|
|||
}])
|
||||
})
|
||||
|
||||
test("message2event: reply to a Discord member join (who didn't join on Matrix)", async t => {
|
||||
const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general)
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.text",
|
||||
body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "<blockquote><strong>PEASANT!!</strong> joined the room</blockquote>when the broke friend who we pay to bring food shows up at the medieval lord party",
|
||||
"m.mentions": {}
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: reply to a Discord member join (who did join on Matrix)", async t => {
|
||||
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES ('1461677775554478161', 'peasant321_76775', 'peasant321_76775', '@_ooye_peasant321_76775:cadence.moe')").run()
|
||||
const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general)
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.text",
|
||||
body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<blockquote><a href="https://matrix.to/#/@_ooye_peasant321_76775:cadence.moe">PEASANT!!</a> joined the room</blockquote>when the broke friend who we pay to bring food shows up at the medieval lord party`,
|
||||
"m.mentions": {}
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: simple written @mention for matrix user", async t => {
|
||||
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
|
||||
api: {
|
||||
|
|
|
|||
|
|
@ -19,24 +19,30 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
|||
*/
|
||||
async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) {
|
||||
const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get()
|
||||
/** @type {{"m.mentions"?: any, "m.in_reply_to"?: any}} */
|
||||
const ellieMode = false //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude.
|
||||
|
||||
/** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */
|
||||
const context = {}
|
||||
let suffix = ""
|
||||
if (branchedFromEventID) {
|
||||
// Need to figure out who sent that event...
|
||||
const event = await di.api.getEvent(parentRoomID, branchedFromEventID)
|
||||
context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}}
|
||||
if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]}
|
||||
if (!ellieMode) {
|
||||
//...And actually branch from that event (if configured to do so)
|
||||
suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]"
|
||||
context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"}
|
||||
}
|
||||
}
|
||||
|
||||
const msgtype = creatorMxid ? "m.emote" : "m.text"
|
||||
const template = creatorMxid ? "started a thread:" : "Thread started:"
|
||||
const template = creatorMxid ? "started a thread called" : "New thread started:"
|
||||
const via = await mxUtils.getViaServersQuery(threadRoomID, di.api)
|
||||
let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}`
|
||||
let body = `${template} \"${thread.name}\" in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}`
|
||||
|
||||
return {
|
||||
msgtype,
|
||||
body,
|
||||
"m.mentions": {},
|
||||
...context
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,8 +49,7 @@ test("thread2announcement: no known creator, no branched from event", async t =>
|
|||
}, {api: viaApi})
|
||||
t.deepEqual(content, {
|
||||
msgtype: "m.text",
|
||||
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
||||
"m.mentions": {}
|
||||
body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -61,8 +60,7 @@ test("thread2announcement: known creator, no branched from event", async t => {
|
|||
}, {api: viaApi})
|
||||
t.deepEqual(content, {
|
||||
msgtype: "m.emote",
|
||||
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
||||
"m.mentions": {}
|
||||
body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -85,12 +83,14 @@ test("thread2announcement: no known creator, branched from discord event", async
|
|||
})
|
||||
t.deepEqual(content, {
|
||||
msgtype: "m.text",
|
||||
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
||||
"m.mentions": {},
|
||||
body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]",
|
||||
"m.relates_to": {
|
||||
"event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
|
||||
"is_falling_back": false,
|
||||
"m.in_reply_to": {
|
||||
event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
|
||||
}
|
||||
"event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -114,12 +114,14 @@ test("thread2announcement: known creator, branched from discord event", async t
|
|||
})
|
||||
t.deepEqual(content, {
|
||||
msgtype: "m.emote",
|
||||
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
||||
"m.mentions": {},
|
||||
body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]",
|
||||
"m.relates_to": {
|
||||
"event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
|
||||
"is_falling_back": false,
|
||||
"m.in_reply_to": {
|
||||
event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
|
||||
}
|
||||
"event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -143,14 +145,51 @@ test("thread2announcement: no known creator, branched from matrix event", async
|
|||
})
|
||||
t.deepEqual(content, {
|
||||
msgtype: "m.text",
|
||||
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
||||
body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]",
|
||||
"m.mentions": {
|
||||
user_ids: ["@cadence:cadence.moe"]
|
||||
},
|
||||
"m.relates_to": {
|
||||
"event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4",
|
||||
"is_falling_back": false,
|
||||
"m.in_reply_to": {
|
||||
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
|
||||
}
|
||||
"event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4",
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("thread2announcement: known creator, branched from matrix event", async t => {
|
||||
const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", "@_ooye_crunch_god:cadence.moe", {
|
||||
name: "test thread",
|
||||
id: "1128118177155526666"
|
||||
}, {
|
||||
api: {
|
||||
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", {
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "so can you reply to my webhook uwu"
|
||||
},
|
||||
sender: "@cadence:cadence.moe"
|
||||
}),
|
||||
...viaApi
|
||||
}
|
||||
})
|
||||
t.deepEqual(content, {
|
||||
msgtype: "m.emote",
|
||||
body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]",
|
||||
"m.mentions": {
|
||||
user_ids: ["@cadence:cadence.moe"]
|
||||
},
|
||||
"m.relates_to": {
|
||||
"event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4",
|
||||
"is_falling_back": false,
|
||||
"m.in_reply_to": {
|
||||
"event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4",
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ const vote = sync.require("./actions/poll-vote")
|
|||
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
|
||||
/** @type {import("../discord/interactions/matrix-info")} */
|
||||
const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info")
|
||||
/** @type {import("../agi/listener")} */
|
||||
const agiListener = sync.require("../agi/listener")
|
||||
|
||||
const {Semaphore} = require("@chriscdn/promise-semaphore")
|
||||
const checkMissedPinsSema = new Semaphore()
|
||||
|
|
@ -210,7 +212,7 @@ module.exports = {
|
|||
const channelID = thread.parent_id || undefined
|
||||
const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||
if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate)
|
||||
const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread)
|
||||
const threadRoomID = await createRoom.ensureRoom(thread.id)
|
||||
await announceThread.announceThread(parentRoomID, threadRoomID, thread)
|
||||
},
|
||||
|
||||
|
|
@ -303,7 +305,10 @@ module.exports = {
|
|||
|
||||
if (message.webhook_id) {
|
||||
const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get()
|
||||
if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
|
||||
if (row) { // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
|
||||
await agiListener.process(message, channel, guild, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only!
|
||||
|
|
|
|||
25
src/db/migrations/0037-agi.sql
Normal file
25
src/db/migrations/0037-agi.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE "agi_prior_message" (
|
||||
"channel_id" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"avatar_url" TEXT NOT NULL,
|
||||
"use_caps" INTEGER NOT NULL,
|
||||
"use_punct" INTEGER NOT NULL,
|
||||
"use_apos" INTEGER NOT NULL,
|
||||
"timestamp" INTEGER NOT NULL,
|
||||
PRIMARY KEY("channel_id")
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE "agi_optout" (
|
||||
"guild_id" TEXT NOT NULL,
|
||||
PRIMARY KEY("guild_id")
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE "agi_cooldown" (
|
||||
"guild_id" TEXT NOT NULL,
|
||||
"timestamp" INTEGER,
|
||||
PRIMARY KEY("guild_id")
|
||||
) WITHOUT ROWID;
|
||||
|
||||
COMMIT;
|
||||
19
src/db/orm-defs.d.ts
vendored
19
src/db/orm-defs.d.ts
vendored
|
|
@ -1,4 +1,23 @@
|
|||
export type Models = {
|
||||
agi_prior_message: {
|
||||
channel_id: string
|
||||
username: string
|
||||
avatar_url: string
|
||||
use_caps: number
|
||||
use_punct: number
|
||||
use_apos: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
agi_optout: {
|
||||
guild_id: string
|
||||
}
|
||||
|
||||
agi_cooldown: {
|
||||
guild_id: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
app_user_install: {
|
||||
guild_id: string
|
||||
app_bot_id: string
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ async function _interact({guild_id, data}, {api}) {
|
|||
// from Matrix
|
||||
const event = await api.getEvent(message.room_id, message.event_id)
|
||||
const via = await utils.getViaServersQuery(message.room_id, api)
|
||||
|
||||
const channelsInGuild = discord.guildChannelMap.get(guild_id)
|
||||
assert(channelsInGuild)
|
||||
const inChannels = channelsInGuild
|
||||
|
|
@ -62,11 +61,6 @@ async function _interact({guild_id, data}, {api}) {
|
|||
.map(/** @returns {DiscordTypes.APIGuildChannel} */ cid => discord.channels.get(cid))
|
||||
.sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels))
|
||||
.filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get())
|
||||
let inChannelsText = inChannels.map(c => `<#${c.id}>`).join(" • ")
|
||||
if (inChannelsText.length > 1024) {
|
||||
inChannelsText = `In ${inChannels.length} channels`
|
||||
}
|
||||
|
||||
const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get()
|
||||
let name = matrixMember?.displayname || event.sender
|
||||
let avatar = utils.getPublicUrlForMxc(matrixMember?.avatar_url)
|
||||
|
|
@ -104,7 +98,7 @@ async function _interact({guild_id, data}, {api}) {
|
|||
color: 0x0dbd8b,
|
||||
fields: [{
|
||||
name: "In Channels",
|
||||
value: inChannelsText
|
||||
value: inChannels.map(c => `<#${c.id}>`).join(" • ")
|
||||
}, {
|
||||
name: "\u200b",
|
||||
value: idInfo
|
||||
|
|
|
|||
|
|
@ -182,394 +182,6 @@ function filterTo(xs, fn) {
|
|||
return filtered
|
||||
}
|
||||
|
||||
const supportedPlaintextPreviewExtensions = new Set([
|
||||
"4d",
|
||||
"abnf",
|
||||
"accesslog",
|
||||
"actionscript",
|
||||
"ada",
|
||||
"adoc",
|
||||
"alan",
|
||||
"angelscript",
|
||||
"ansi",
|
||||
"apache",
|
||||
"apacheconf",
|
||||
"applescript",
|
||||
"arcade",
|
||||
"arduino",
|
||||
"arm",
|
||||
"armasm",
|
||||
"as",
|
||||
"asc",
|
||||
"asciidoc",
|
||||
"aspectj",
|
||||
"ass",
|
||||
"atom",
|
||||
"autohotkey",
|
||||
"autoit",
|
||||
"avrasm",
|
||||
"awk",
|
||||
"axapta",
|
||||
"bash",
|
||||
"basic",
|
||||
"bat",
|
||||
"bbcode",
|
||||
"bf",
|
||||
"bind",
|
||||
"blade",
|
||||
"bnf",
|
||||
"brainfuck",
|
||||
"c",
|
||||
"c++",
|
||||
"cal",
|
||||
"capnp",
|
||||
"capnproto",
|
||||
"cc",
|
||||
"chaos",
|
||||
"chapel",
|
||||
"chpl",
|
||||
"cisco",
|
||||
"clj",
|
||||
"clojure",
|
||||
"cls",
|
||||
"cmake.in",
|
||||
"cmake",
|
||||
"cmd",
|
||||
"coffee",
|
||||
"coffeescript",
|
||||
"console",
|
||||
"coq",
|
||||
"cos",
|
||||
"cpc",
|
||||
"cpp",
|
||||
"cr",
|
||||
"craftcms",
|
||||
"crm",
|
||||
"crmsh",
|
||||
"crystal",
|
||||
"cs",
|
||||
"csharp",
|
||||
"cshtml",
|
||||
"cson",
|
||||
"csp",
|
||||
"css",
|
||||
"csv",
|
||||
"cxx",
|
||||
"cypher",
|
||||
"d",
|
||||
"dart",
|
||||
"delphi",
|
||||
"dfm",
|
||||
"diff",
|
||||
"django",
|
||||
"dns",
|
||||
"docker",
|
||||
"dockerfile",
|
||||
"dos",
|
||||
"dpr",
|
||||
"dsconfig",
|
||||
"dst",
|
||||
"dts",
|
||||
"dust",
|
||||
"dylan",
|
||||
"ebnf",
|
||||
"elixir",
|
||||
"elm",
|
||||
"erl",
|
||||
"erlang",
|
||||
"ex",
|
||||
"extempore",
|
||||
"f90",
|
||||
"f95",
|
||||
"fix",
|
||||
"fortran",
|
||||
"freepascal",
|
||||
"fs",
|
||||
"fsharp",
|
||||
"gams",
|
||||
"gauss",
|
||||
"gawk",
|
||||
"gcode",
|
||||
"gdscript",
|
||||
"gemspec",
|
||||
"gf",
|
||||
"gherkin",
|
||||
"glsl",
|
||||
"gms",
|
||||
"gn",
|
||||
"gni",
|
||||
"go",
|
||||
"godot",
|
||||
"golang",
|
||||
"golo",
|
||||
"gololang",
|
||||
"gradle",
|
||||
"graph",
|
||||
"groovy",
|
||||
"gss",
|
||||
"gyp",
|
||||
"h",
|
||||
"h++",
|
||||
"haml",
|
||||
"handlebars",
|
||||
"haskell",
|
||||
"haxe",
|
||||
"hbs",
|
||||
"hcl",
|
||||
"hh",
|
||||
"hpp",
|
||||
"hs",
|
||||
"html.handlebars",
|
||||
"html.hbs",
|
||||
"html",
|
||||
"http",
|
||||
"https",
|
||||
"hx",
|
||||
"hxx",
|
||||
"hy",
|
||||
"hylang",
|
||||
"i",
|
||||
"i7",
|
||||
"iced",
|
||||
"iecst",
|
||||
"inform7",
|
||||
"ini",
|
||||
"ino",
|
||||
"instances",
|
||||
"iol",
|
||||
"irb",
|
||||
"irpf90",
|
||||
"java",
|
||||
"javascript",
|
||||
"jinja",
|
||||
"jolie",
|
||||
"js",
|
||||
"json",
|
||||
"jsp",
|
||||
"jsx",
|
||||
"julia-repl",
|
||||
"julia",
|
||||
"k",
|
||||
"kaos",
|
||||
"kdb",
|
||||
"kotlin",
|
||||
"kt",
|
||||
"lasso",
|
||||
"lassoscript",
|
||||
"lazarus",
|
||||
"ldif",
|
||||
"leaf",
|
||||
"lean",
|
||||
"less",
|
||||
"lfm",
|
||||
"lisp",
|
||||
"livecodeserver",
|
||||
"livescript",
|
||||
"ln",
|
||||
"lock",
|
||||
"log",
|
||||
"lpr",
|
||||
"ls",
|
||||
"ls",
|
||||
"lua",
|
||||
"mak",
|
||||
"make",
|
||||
"makefile",
|
||||
"markdown",
|
||||
"mathematica",
|
||||
"matlab",
|
||||
"mawk",
|
||||
"maxima",
|
||||
"md",
|
||||
"mel",
|
||||
"mercury",
|
||||
"mirc",
|
||||
"mizar",
|
||||
"mk",
|
||||
"mkd",
|
||||
"mkdown",
|
||||
"ml",
|
||||
"ml",
|
||||
"mm",
|
||||
"mma",
|
||||
"mojolicious",
|
||||
"monkey",
|
||||
"moon",
|
||||
"moonscript",
|
||||
"mrc",
|
||||
"n1ql",
|
||||
"nawk",
|
||||
"nc",
|
||||
"never",
|
||||
"nginx",
|
||||
"nginxconf",
|
||||
"nim",
|
||||
"nimrod",
|
||||
"nix",
|
||||
"nsis",
|
||||
"obj-c",
|
||||
"obj-c++",
|
||||
"objc",
|
||||
"objective-c++",
|
||||
"objectivec",
|
||||
"ocaml",
|
||||
"ocl",
|
||||
"ol",
|
||||
"openscad",
|
||||
"osascript",
|
||||
"oxygene",
|
||||
"p21",
|
||||
"parser3",
|
||||
"pas",
|
||||
"pascal",
|
||||
"patch",
|
||||
"pcmk",
|
||||
"perl",
|
||||
"pf.conf",
|
||||
"pf",
|
||||
"pgsql",
|
||||
"php",
|
||||
"php3",
|
||||
"php4",
|
||||
"php5",
|
||||
"php6",
|
||||
"php7",
|
||||
"pl",
|
||||
"plaintext",
|
||||
"plist",
|
||||
"pm",
|
||||
"podspec",
|
||||
"pony",
|
||||
"postgres",
|
||||
"postgresql",
|
||||
"powershell",
|
||||
"pp",
|
||||
"processing",
|
||||
"profile",
|
||||
"prolog",
|
||||
"properties",
|
||||
"proto",
|
||||
"protobuf",
|
||||
"ps",
|
||||
"ps1",
|
||||
"puppet",
|
||||
"py",
|
||||
"pycon",
|
||||
"python-repl",
|
||||
"python",
|
||||
"qml",
|
||||
"r",
|
||||
"razor-cshtml",
|
||||
"razor",
|
||||
"rb",
|
||||
"re",
|
||||
"reasonml",
|
||||
"rebol",
|
||||
"red-system",
|
||||
"red",
|
||||
"redbol",
|
||||
"rf",
|
||||
"rib",
|
||||
"robot",
|
||||
"rpm-spec",
|
||||
"rpm-specfile",
|
||||
"rpm",
|
||||
"rs",
|
||||
"rsl",
|
||||
"rss",
|
||||
"ruby",
|
||||
"ruleslanguage",
|
||||
"rust",
|
||||
"sas",
|
||||
"SAS",
|
||||
"sc",
|
||||
"scad",
|
||||
"scala",
|
||||
"scheme",
|
||||
"sci",
|
||||
"scilab",
|
||||
"scl",
|
||||
"scss",
|
||||
"sh",
|
||||
"shell",
|
||||
"shexc",
|
||||
"smali",
|
||||
"smalltalk",
|
||||
"sml",
|
||||
"sol",
|
||||
"solidity",
|
||||
"spec",
|
||||
"specfile",
|
||||
"sql",
|
||||
"srt",
|
||||
"ssa",
|
||||
"st",
|
||||
"stan",
|
||||
"stanfuncs",
|
||||
"stata",
|
||||
"step",
|
||||
"stp",
|
||||
"structured-text",
|
||||
"styl",
|
||||
"stylus",
|
||||
"subunit",
|
||||
"supercollider",
|
||||
"svelte",
|
||||
"svg",
|
||||
"swift",
|
||||
"tao",
|
||||
"tap",
|
||||
"tcl",
|
||||
"terraform",
|
||||
"tex",
|
||||
"text",
|
||||
"tf",
|
||||
"thor",
|
||||
"thrift",
|
||||
"tk",
|
||||
"toml",
|
||||
"tp",
|
||||
"ts",
|
||||
"tsql",
|
||||
"tsx",
|
||||
"ttml",
|
||||
"twig",
|
||||
"txt",
|
||||
"typescript",
|
||||
"unicorn-rails-log",
|
||||
"v",
|
||||
"vala",
|
||||
"vb",
|
||||
"vba",
|
||||
"vbnet",
|
||||
"vbs",
|
||||
"vbscript",
|
||||
"verilog",
|
||||
"vhdl",
|
||||
"vim",
|
||||
"vtt",
|
||||
"wl",
|
||||
"x++",
|
||||
"x86asm",
|
||||
"xhtml",
|
||||
"xjb",
|
||||
"xl",
|
||||
"xml",
|
||||
"xpath",
|
||||
"xq",
|
||||
"xquery",
|
||||
"xsd",
|
||||
"xsl",
|
||||
"xtlang",
|
||||
"xtm",
|
||||
"yaml",
|
||||
"yml",
|
||||
"zep",
|
||||
"zephir",
|
||||
"zone",
|
||||
"zsh"
|
||||
])
|
||||
|
||||
module.exports.getPermissions = getPermissions
|
||||
module.exports.getDefaultPermissions = getDefaultPermissions
|
||||
module.exports.hasPermission = hasPermission
|
||||
|
|
@ -582,4 +194,3 @@ module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact
|
|||
module.exports.getPublicUrlForCdn = getPublicUrlForCdn
|
||||
module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage
|
||||
module.exports.filterTo = filterTo
|
||||
module.exports.supportedPlaintextPreviewExtensions = supportedPlaintextPreviewExtensions
|
||||
|
|
|
|||
|
|
@ -39,14 +39,20 @@ async function resolvePendingFiles(message) {
|
|||
if ("key" in p) {
|
||||
// Encrypted file
|
||||
const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url"))
|
||||
await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body).pipe(d))
|
||||
await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(
|
||||
// @ts-ignore
|
||||
res.body
|
||||
).pipe(d))
|
||||
return {
|
||||
name: p.name,
|
||||
file: d
|
||||
}
|
||||
} else {
|
||||
// Unencrypted file
|
||||
const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body))
|
||||
const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(
|
||||
// @ts-ignore
|
||||
res.body
|
||||
))
|
||||
return {
|
||||
name: p.name,
|
||||
file: body
|
||||
|
|
|
|||
|
|
@ -471,6 +471,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
|
|||
// @ts-ignore - typescript doesn't know about indices yet
|
||||
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]),
|
||||
ensureJoined: [],
|
||||
/**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that "everyone" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that "everyone" is a valid enum value.
|
||||
allowedMentionsParse: ["everyone"],
|
||||
allowedMentionsUsers: []
|
||||
}
|
||||
|
|
@ -545,6 +546,7 @@ async function getL1L2ReplyLine(called = false) {
|
|||
async function eventToMessage(event, guild, channel, di) {
|
||||
let displayName = event.sender
|
||||
let avatarURL = undefined
|
||||
/**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that neither "users" no "roles" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that both are valid enum values.
|
||||
const allowedMentionsParse = ["users", "roles"]
|
||||
const allowedMentionsUsers = []
|
||||
/** @type {string[]} */
|
||||
|
|
@ -894,8 +896,7 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
let preNode
|
||||
if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) {
|
||||
if (preNode.firstChild?.nodeName === "CODE") {
|
||||
let ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1]
|
||||
if (!dUtils.supportedPlaintextPreviewExtensions.has(ext)) ext = "txt"
|
||||
const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt"
|
||||
const filename = `inline_code.${ext}`
|
||||
// Build the replacement <code> node
|
||||
const replacementCode = doc.createElement("code")
|
||||
|
|
|
|||
|
|
@ -1155,38 +1155,6 @@ test("event2message: code blocks are uploaded as attachments instead if they con
|
|||
)
|
||||
})
|
||||
|
||||
test("event2message: code blocks are uploaded as attachments instead if they contain incompatible backticks (use txt extension if discord does not recognise the language)", async t => {
|
||||
t.deepEqual(
|
||||
await eventToMessage({
|
||||
type: "m.room.message",
|
||||
sender: "@cadence:cadence.moe",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "wrong body",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: 'So if you run code like this<pre><code class="language-if">System.out.println("```");</code></pre>it should print a markdown formatted code block'
|
||||
},
|
||||
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
|
||||
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
|
||||
}),
|
||||
{
|
||||
ensureJoined: [],
|
||||
messagesToDelete: [],
|
||||
messagesToEdit: [],
|
||||
messagesToSend: [{
|
||||
username: "cadence [they]",
|
||||
content: "So if you run code like this `[inline_code.txt]` it should print a markdown formatted code block",
|
||||
attachments: [{id: "0", filename: "inline_code.txt"}],
|
||||
pendingFiles: [{name: "inline_code.txt", buffer: Buffer.from('System.out.println("```");')}],
|
||||
avatar_url: undefined,
|
||||
allowed_mentions: {
|
||||
parse: ["users", "roles"]
|
||||
}
|
||||
}]
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test("event2message: code blocks are uploaded as attachments instead if they contain incompatible backticks (default to txt file extension)", async t => {
|
||||
t.deepEqual(
|
||||
await eventToMessage({
|
||||
|
|
|
|||
|
|
@ -156,9 +156,14 @@ async function sendError(roomID, source, type, e, payload) {
|
|||
} catch (e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
* @param {(event: Ty.Event.Outer<any> & {type: any, redacts:any, state_key:any}, ...args: any)=>any} fn
|
||||
*/
|
||||
function guard(type, fn) {
|
||||
return async function(event, ...args) {
|
||||
return async function(/** @type {Ty.Event.Outer<any>} */ event, /** @type {any} */ ...args) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
return await fn(event, ...args)
|
||||
} catch (e) {
|
||||
await sendError(event.room_id, "Matrix", type, e, event)
|
||||
|
|
@ -205,16 +210,66 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
|
|||
*/
|
||||
async event => {
|
||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||
|
||||
let processCommands = true
|
||||
if (event.content["m.relates_to"]?.rel_type === "m.thread") {
|
||||
const toRedact = event.room_id
|
||||
const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id)
|
||||
processCommands = false
|
||||
|
||||
if (bridgedTo) event.room_id = bridgedTo;
|
||||
else if (await bridgeThread(event)) {
|
||||
api.redactEvent(toRedact, event.event_id)
|
||||
event.content["m.relates_to"] = undefined
|
||||
api.sendEvent(event.room_id, event.type, {...event.content, body: event.content.body+"\n ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"<br> ~ "+event.sender :undefined })
|
||||
}
|
||||
}
|
||||
|
||||
const messageResponses = await sendEvent.sendEvent(event)
|
||||
if (!messageResponses.length) return
|
||||
if (event.type === "m.room.message" && event.content.msgtype === "m.text") {
|
||||
// @ts-ignore
|
||||
await matrixCommandHandler.execute(event)
|
||||
|
||||
if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) {
|
||||
await matrixCommandHandler.parseAndExecute(
|
||||
// @ts-ignore - TypeScript doesn't know that the event.content.msgtype === "m.text" check ensures that event isn't of type Ty.Event.Outer_M_Room_Message_File (which, indeed, wouldn't fit here)
|
||||
event
|
||||
)
|
||||
}
|
||||
|
||||
retrigger.messageFinishedBridging(event.event_id)
|
||||
await api.ackEvent(event)
|
||||
}))
|
||||
|
||||
/**
|
||||
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event Used for determining the branching-point and the title; any relation data will be stripped and its room_id will mutate to the target thread-room, if one gets created.
|
||||
* @returns {Promise<boolean>} whether a thread-room was created
|
||||
*/
|
||||
async function bridgeThread(event) {
|
||||
/** @type {string} */ // @ts-ignore
|
||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
||||
const channel = discord.channels.get(channelID)
|
||||
const guildID = channel?.["guild_id"]
|
||||
if (!guildID) return false; //Room not bridged? We don't care. It's a Matrix-native room, let Matrix users have standard Matrix-native threads there.
|
||||
|
||||
const eventID = event.content["m.relates_to"]?.event_id
|
||||
if (!eventID) throw new Error("There was an event sent inside SOME Matrix thread, but it lacked any information as to what thread it actually was!"); //An „ugly error” is justified because if something like this DOES happen, then that means that it should be reported to us, as there is some broken client out there that we should account for.
|
||||
const messageID = select("event_message", "message_id", {event_id: eventID}).pluck().get()
|
||||
if (!messageID) return false; //Message not bridged? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. We COULD technically create a "headless" thread on Discord side and bridge it to a new thread-room, but that comes with a whole host of complications on its own (notably: what do we do if the message gets bridged later (by reaction emoji), and then hypothetically gets its own thread; and: getThreadRoomFromThreadEvent will have to be much more complex than a simple DB call (probably a whole new DB table would have to be created, just to hold these Matrix-branched-but-headless-on-Discord threads) because the simple „MX event --(db)--> Discord Message --(Discord spec)--> Discord thread” relation would no longer hold true), which may not be worth it, as an unbridged message in a bridged channel is already an edge-case (so it seems pointless to introduce a whole bunch of edgier-cases that handling this edge-case "properly" would bring).
|
||||
|
||||
let name = event.content.body
|
||||
if (name.startsWith("/thread ")) name = name.substring(8);
|
||||
else name = (await api.getEvent(event.room_id, eventID)).content.body;
|
||||
name = name.length < 100 ? name.replaceAll("\n", " ") : name.slice(0, 96).replaceAll("\n", " ") + "..."
|
||||
|
||||
try {
|
||||
event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id)
|
||||
return true;
|
||||
}
|
||||
catch (e){
|
||||
if (e.message?.includes("50024")) return false; //Tried to created a thread in a thread (see: https://docs.discord.com/developers/topics/opcodes-and-status-codes)? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. Same case as for message-not-bridged, except there at least exists a HYPOTHETICAL solution (just one so unwieldly that it's nonsensical to dedicate resources to), wheres here I don't know what could possibly be done at all.
|
||||
else throw e; //In here (unlike in matrix-command-handler.js), there are much fewer things that could "intentionally" go wrong (both thread double-creation and too-long names shouldn't be possible due to earlier checks). As such, if anything breaks, it should be reported to OOYE for further investigation, which the user should do when encountering an "ugly error" (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an "ugly error" upstream.
|
||||
}
|
||||
}
|
||||
|
||||
sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
|
||||
/**
|
||||
* @param {Ty.Event.Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for
|
||||
|
|
|
|||
|
|
@ -261,8 +261,65 @@ const commands = [{
|
|||
body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission."
|
||||
})
|
||||
}
|
||||
|
||||
const relation = event.content["m.relates_to"]
|
||||
let isFallingBack = false;
|
||||
let branchedFromMxEvent = relation?.["m.in_reply_to"]?.event_id // By default, attempt to branch the thread from the message to which /thread was replying.
|
||||
if (relation?.rel_type === "m.thread") branchedFromMxEvent = relation?.event_id // If /thread was sent inside a Matrix thread, attempt to branch the Discord thread from the message, which that Matrix thread already is branching from.
|
||||
if (!branchedFromMxEvent){
|
||||
branchedFromMxEvent = event.event_id // If /thread wasn't replying to anything (ie. branchedFromMxEvent was undefined at initial assignment), or if the event was somehow malformed (in such a way that that - one way or another - branchedFromMxEvent ended up being undefined, even if according to the spec it shouldn't), branch the thread from the /thread command-message that created it.
|
||||
isFallingBack = true;
|
||||
}
|
||||
const branchedFromDiscordMessage = select("event_message", "message_id", {event_id: branchedFromMxEvent}).pluck().get()
|
||||
|
||||
await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")})
|
||||
if (words.length < 2){
|
||||
if (isFallingBack) return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
msgtype: "m.text",
|
||||
body: "**`/thread` usage:**\nRun this command as `/thread [Thread Name]` to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:\n* If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The `Thread Name` argument must be provided in this case, otherwise you get this help message.\n* If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.\n* If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "<strong><code>/thread</code> usage:</strong><br>Run this command as <code>/thread [Thread Name]</code> to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:<br><ul><li>If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The <code>Thread Name</code> argument must be provided in this case, otherwise you get this help message.</li><li>If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.</li><li>If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.</li></ul>"
|
||||
})
|
||||
words[1] = (await api.getEvent(event.room_id, branchedFromMxEvent)).content.body.replaceAll("\n", " ")
|
||||
words[1] = words[1].length < 100 ? words[1] : words[1].slice(0, 96) + "..."
|
||||
}
|
||||
|
||||
try {
|
||||
if (branchedFromDiscordMessage) return await discord.snow.channel.createThreadWithMessage(channelID, branchedFromDiscordMessage, {name: words.slice(1).join(" ")}) //can't just return the promise directly like in 99% of other cases here in commands, otherwise the error-handling below will not work
|
||||
else {return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
msgtype: "m.text",
|
||||
body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID `"+branchedFromMxEvent+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID <code>"+branchedFromMxEvent+"</code> on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported."
|
||||
})};
|
||||
}
|
||||
catch (e){
|
||||
/**@type {string|undefined} */
|
||||
let err = e.message // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes
|
||||
|
||||
if (err?.includes("160004")) {
|
||||
if (isFallingBack) throw e; //Discord claims that there already exists a thread for the message ran this command was ran on, but that doesn't make logical sense, as it doesn't seem like it was ran on any message. Either the Matrix client did something funny with reply/thread tags, or this is a logic error on our side. At any rate, this should be reported to OOYE for further investigation, which the user should do when encountering an „ugly error” (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an „ugly error” upstream.
|
||||
const thread = mxUtils.getThreadRoomFromThreadEvent(branchedFromMxEvent)
|
||||
return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
msgtype: "m.text",
|
||||
body: "There already exists a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue by creating the bridge. (If you're said admin and you can see that said bridge already exists, but this error message is still showing up, please report that as a bug.)")
|
||||
})
|
||||
}
|
||||
if (err?.includes("50024")) return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
msgtype: "m.text",
|
||||
body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?"
|
||||
})
|
||||
if (err?.includes("50035")) return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
msgtype: "m.text",
|
||||
body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)"
|
||||
})
|
||||
|
||||
throw e //Some other error happened, one that OOYE didn't anticipate the possibility of? It should be reported to us for further investigation, which the user should do when encountering an „ugly error” (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an „ugly error” upstream.
|
||||
}
|
||||
}
|
||||
)
|
||||
}, {
|
||||
|
|
@ -321,8 +378,11 @@ const commands = [{
|
|||
}]
|
||||
|
||||
|
||||
/** @type {CommandExecute} */
|
||||
async function execute(event) {
|
||||
/**
|
||||
* @param {Ty.Event.Outer_M_Room_Message} event
|
||||
* @returns {Promise<any>|undefined} the executed command's in-process promise or undefined if no command execution was performed
|
||||
*/
|
||||
function parseAndExecute(event) {
|
||||
let realBody = event.content.body
|
||||
while (realBody.startsWith("> ")) {
|
||||
const i = realBody.indexOf("\n")
|
||||
|
|
@ -342,8 +402,8 @@ async function execute(event) {
|
|||
const command = commands.find(c => c.aliases.includes(commandName))
|
||||
if (!command) return
|
||||
|
||||
await command.execute(event, realBody, words)
|
||||
return command.execute(event, realBody, words)
|
||||
}
|
||||
|
||||
module.exports.execute = execute
|
||||
module.exports.parseAndExecute = parseAndExecute
|
||||
module.exports.onReactionAdd = onReactionAdd
|
||||
|
|
|
|||
|
|
@ -78,14 +78,10 @@ function readRegistration() {
|
|||
/** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore
|
||||
let reg = readRegistration()
|
||||
|
||||
if (reg) {
|
||||
fs.watch(registrationFilePath, {persistent: false}, () => {
|
||||
let newReg = readRegistration()
|
||||
if (newReg) {
|
||||
Object.assign(reg, newReg)
|
||||
}
|
||||
})
|
||||
}
|
||||
fs.watch(registrationFilePath, {persistent: false}, () => {
|
||||
let newReg = readRegistration()
|
||||
Object.assign(reg, newReg)
|
||||
})
|
||||
|
||||
module.exports.registrationFilePath = registrationFilePath
|
||||
module.exports.readRegistration = readRegistration
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const assert = require("assert").strict
|
|||
const Ty = require("../types")
|
||||
const {tag} = require("@cloudrac3r/html-template-tag")
|
||||
const passthrough = require("../passthrough")
|
||||
const {db} = passthrough
|
||||
const {db, select} = passthrough
|
||||
|
||||
const {reg} = require("./read-registration")
|
||||
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
||||
|
|
@ -385,6 +385,16 @@ async function setUserPowerCascade(spaceID, mxid, power, api) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {undefined|string?} eventID
|
||||
*/ //^For some reason, „?” doesn't include Undefined and it needs to be explicitly specified
|
||||
function getThreadRoomFromThreadEvent(eventID){
|
||||
if (!eventID) return eventID;
|
||||
const threadID = select("event_message", "message_id", {event_id: eventID}).pluck().get() //Discord thread ID === its message ID
|
||||
if (!threadID) return threadID;
|
||||
return select("channel_room", "room_id", {channel_id: threadID}).pluck().get()
|
||||
}
|
||||
|
||||
module.exports.bot = bot
|
||||
module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
|
||||
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
|
||||
|
|
@ -400,3 +410,4 @@ module.exports.removeCreatorsFromPowerLevels = removeCreatorsFromPowerLevels
|
|||
module.exports.getEffectivePower = getEffectivePower
|
||||
module.exports.setUserPower = setUserPower
|
||||
module.exports.setUserPowerCascade = setUserPowerCascade
|
||||
module.exports.getThreadRoomFromThreadEvent = getThreadRoomFromThreadEvent
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
const {select} = require("../passthrough")
|
||||
const {test} = require("supertape")
|
||||
const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower} = require("./utils")
|
||||
const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower, getThreadRoomFromThreadEvent} = require("./utils")
|
||||
const util = require("util")
|
||||
|
||||
/** @param {string[]} mxids */
|
||||
|
|
@ -417,4 +417,38 @@ test("set user power: privileged users must demote themselves", async t => {
|
|||
t.equal(called, 3)
|
||||
})
|
||||
|
||||
test("getThreadRoomFromThreadEvent: real message, but without a thread", t => {
|
||||
const room = getThreadRoomFromThreadEvent("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
|
||||
const msg = "Expected null/undefined, got: "+room
|
||||
if(room) t.fail(msg);
|
||||
else t.pass(msg)
|
||||
})
|
||||
|
||||
test("getThreadRoomFromThreadEvent: real message with a thread", t => {
|
||||
const room = getThreadRoomFromThreadEvent("$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg")
|
||||
t.equal(room, "!FuDZhlOAtqswlyxzeR:cadence.moe")
|
||||
})
|
||||
|
||||
test("getThreadRoomFromThreadEvent: fake message", t => {
|
||||
const room = getThreadRoomFromThreadEvent("$ThisEvent-IdDoesNotExistInTheDatabase4Sure")
|
||||
const msg = "Expected null/undefined, got: "+room
|
||||
if(room) t.fail(msg);
|
||||
else t.pass(msg)
|
||||
})
|
||||
|
||||
test("getThreadRoomFromThreadEvent: null", t => {
|
||||
const room = getThreadRoomFromThreadEvent(null)
|
||||
t.equal(room, null)
|
||||
})
|
||||
|
||||
test("getThreadRoomFromThreadEvent: undefined", t => {
|
||||
const room = getThreadRoomFromThreadEvent(undefined)
|
||||
t.equal(room, undefined)
|
||||
})
|
||||
|
||||
test("getThreadRoomFromThreadEvent: no value at all", t => {
|
||||
const room = getThreadRoomFromThreadEvent() //This line should be giving a type-error, so it's not @ts-ignored on purpose. This is to test the desired behavior of that function, ie. „it CAN TAKE an undefined VALUE (as tested above), but you can just LEAVE the value completely undefined” (well, you can leave it like that from JS syntax perspective (which is why this test passes), but it makes no sense from usage standpoint, as it just gives back undefined). So this isn't a logic test (that's handled above), as much as it is a TypeScript test.
|
||||
t.equal(room, undefined)
|
||||
})
|
||||
|
||||
module.exports.mockGetEffectivePower = mockGetEffectivePower
|
||||
|
|
|
|||
21
src/types.d.ts
vendored
21
src/types.d.ts
vendored
|
|
@ -190,11 +190,12 @@ export namespace Event {
|
|||
format?: "org.matrix.custom.html"
|
||||
formatted_body?: string,
|
||||
"m.relates_to"?: {
|
||||
"m.in_reply_to": {
|
||||
event_id?: string
|
||||
is_falling_back?: boolean
|
||||
"m.in_reply_to"?: {
|
||||
event_id: string
|
||||
}
|
||||
rel_type?: "m.replace"
|
||||
event_id?: string
|
||||
rel_type?: "m.replace"|"m.thread"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,11 +211,12 @@ export namespace Event {
|
|||
info?: any
|
||||
"page.codeberg.everypizza.msc4193.spoiler"?: boolean
|
||||
"m.relates_to"?: {
|
||||
"m.in_reply_to": {
|
||||
event_id?: string
|
||||
is_falling_back?: boolean
|
||||
"m.in_reply_to"?: {
|
||||
event_id: string
|
||||
}
|
||||
rel_type?: "m.replace"
|
||||
event_id?: string
|
||||
rel_type?: "m.replace"|"m.thread"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -246,11 +248,12 @@ export namespace Event {
|
|||
},
|
||||
info?: any
|
||||
"m.relates_to"?: {
|
||||
"m.in_reply_to": {
|
||||
event_id?: string
|
||||
is_falling_back?: boolean
|
||||
"m.in_reply_to"?: {
|
||||
event_id: string
|
||||
}
|
||||
rel_type?: "m.replace"
|
||||
event_id?: string
|
||||
rel_type?: "m.replace"|"m.thread"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
24
src/web/pug/agi-optout.pug
Normal file
24
src/web/pug/agi-optout.pug
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
extends includes/template.pug
|
||||
|
||||
block body
|
||||
h1.ta-center.fs-display2.fc-green-400 April Fools!
|
||||
.ws7.m-auto
|
||||
.s-prose.fs-body2
|
||||
p Sheesh, wouldn't that be horrible?
|
||||
if guild_id
|
||||
p Fake AI messages have now been #[strong.fc-green-600 deactivated for everyone in your server.]
|
||||
p Hope the prank entertained you. #[a(href="https://cadence.moe/contact") Send love or hate mail here.]
|
||||
|
||||
h2 What actually happened?
|
||||
ul
|
||||
li A secret event was added for the duration of 1st April 2026 (UTC).
|
||||
li If a message matches a preset pattern, a preset response is posted to chat by an AI-ified profile of the previous author.
|
||||
li It only happens at most once per hour in each server.
|
||||
li I tried to design it to not interrupt any serious/sensitive topics. I am deeply sorry if that didn't work out.
|
||||
li No AI generated materials have ever been used in Out Of Your Element: no code, no prose, no images, no jokes.
|
||||
li It'll always deactivate itself on 2nd April, no matter what, and I'll remove the relevant code shortly after.
|
||||
if guild_id
|
||||
.s-prose.fl-grow1.mt16
|
||||
p If you thought it was funny, feel free to opt back in. This affects the entire server, so please be courteous.
|
||||
form(method="post" action=rel(`/agi/optin?guild_id=${guild_id}`))
|
||||
button(type="submit").s-btn.s-btn__muted Opt back in
|
||||
41
src/web/pug/agi.pug
Normal file
41
src/web/pug/agi.pug
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
extends includes/template.pug
|
||||
|
||||
block title
|
||||
title AGI in Discord
|
||||
|
||||
block body
|
||||
style.
|
||||
.ai-gradient {
|
||||
background: linear-gradient(100deg, #fb72f2, #072ea4);
|
||||
color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
h1.ta-center.fs-display2.ai-gradient AGI in Discord:#[br]Revolutionizing the Future of Communications
|
||||
.ws7.m-auto
|
||||
.s-prose.fs-body2
|
||||
p In the ever-changing and turbulent world of AI, it's crucial to always be one step ahead.
|
||||
p That's why Out Of Your Element has partnered with #[strong Grimace AI] to provide you tomorrow's technology, today.
|
||||
ul
|
||||
li #[strong Always online:] Miss your friends when they log off? Now you can talk to facsimiles of them etched into an unfeeling machine, always and forever!
|
||||
li #[strong Smarter than ever:] Pissed off when somebody says something #[em wrong] on the internet? Frustrated with having to stay up all night correcting them? With Grimace Truth (available in Pro+ Ultra plan), all information is certified true to reality, so you'll never have to worry about those frantic Google searches at 3 AM.
|
||||
li #[strong Knows you better than yourself:] We aren't just training on your data; we're copying minds into our personality matrix — including yours. Do you find yourself enjoying the sound of your own voice more than anything else? Our unique simulation of You is here to help.
|
||||
|
||||
h1.mt64.mb32 Frequently Asked Questions
|
||||
.s-link-preview
|
||||
.s-link-preview--header.fd-column
|
||||
.s-link-preview--title.fs-title.pl4 How to opt out?
|
||||
.s-link-preview--details.fc-red-500
|
||||
!= icons.Icons.IconFire
|
||||
= ` 20,000% higher search volume for this question in the last hour`
|
||||
.s-link-preview--body
|
||||
.s-prose
|
||||
h2.fs-body3 Is this really goodbye? 😢😢😢😢😢
|
||||
p I can't convince you to stay?
|
||||
p As not just a customer service representative but someone with a shared vision, I simply want you to know that everyone at Grimace AI will miss all the time that we've shared together with you.
|
||||
form(method="post" action=(guild_id ? rel(`/agi/optout?guild_id=${guild_id}`) : rel("/agi/optout"))).d-flex.g4.mt16
|
||||
button(type="button").s-btn.s-btn__filled Nevermind, I'll stay :)
|
||||
button(type="submit").s-btn.s-btn__danger.s-btn__outlined Opt out for 3 days
|
||||
|
||||
|
||||
div(style="height: 200px")
|
||||
|
|
@ -65,7 +65,8 @@ mixin define-themed-button(name, theme)
|
|||
doctype html
|
||||
html(lang="en")
|
||||
head
|
||||
title Out Of Your Element
|
||||
block title
|
||||
title Out Of Your Element
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css"))
|
||||
//- Please use responsibly!!!!!
|
||||
|
|
|
|||
36
src/web/routes/agi.js
Normal file
36
src/web/routes/agi.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// @ts-check
|
||||
|
||||
const {z} = require("zod")
|
||||
const {defineEventHandler, getValidatedQuery, sendRedirect} = require("h3")
|
||||
const {as, from, sync, db} = require("../../passthrough")
|
||||
|
||||
/** @type {import("../pug-sync")} */
|
||||
const pugSync = sync.require("../pug-sync")
|
||||
|
||||
const schema = {
|
||||
opt: z.object({
|
||||
guild_id: z.string().regex(/^[0-9]+$/)
|
||||
})
|
||||
}
|
||||
|
||||
as.router.get("/agi", defineEventHandler(async event => {
|
||||
return pugSync.render(event, "agi.pug", {})
|
||||
}))
|
||||
|
||||
as.router.get("/agi/optout", defineEventHandler(async event => {
|
||||
return pugSync.render(event, "agi-optout.pug", {})
|
||||
}))
|
||||
|
||||
as.router.post("/agi/optout", defineEventHandler(async event => {
|
||||
const parseResult = await getValidatedQuery(event, schema.opt.safeParse)
|
||||
if (parseResult.success) {
|
||||
db.prepare("INSERT OR IGNORE INTO agi_optout (guild_id) VALUES (?)").run(parseResult.data.guild_id)
|
||||
}
|
||||
return sendRedirect(event, "", 302)
|
||||
}))
|
||||
|
||||
as.router.post("/agi/optin", defineEventHandler(async event => {
|
||||
const {guild_id} = await getValidatedQuery(event, schema.opt.parse)
|
||||
db.prepare("DELETE FROM agi_optout WHERE guild_id = ?").run(guild_id)
|
||||
return sendRedirect(event, `../agi?guild_id=${guild_id}`, 302)
|
||||
}))
|
||||
|
|
@ -125,6 +125,7 @@ as.router.get("/icon.png", defineEventHandler(async event => {
|
|||
|
||||
pugSync.createRoute(as.router, "/ok", "ok.pug")
|
||||
|
||||
sync.require("./routes/agi")
|
||||
sync.require("./routes/download-matrix")
|
||||
sync.require("./routes/download-discord")
|
||||
sync.require("./routes/guild-settings")
|
||||
|
|
|
|||
74
test/data.js
74
test/data.js
|
|
@ -2035,80 +2035,6 @@ module.exports = {
|
|||
tts: false
|
||||
}
|
||||
},
|
||||
reply_to_member_join: {
|
||||
type: 19,
|
||||
content: "when the broke friend who we pay to bring food shows up at the medieval lord party",
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
timestamp: "2026-03-30T12:11:04.443000+00:00",
|
||||
edited_timestamp: null,
|
||||
flags: 0,
|
||||
components: [],
|
||||
id: "1488148556962332692",
|
||||
channel_id: "475599038536744962",
|
||||
author: {
|
||||
id: "576945009408999426",
|
||||
username: "randomllama121",
|
||||
avatar: "08510a70f957106dad1580323c40cd7a",
|
||||
discriminator: "0",
|
||||
public_flags: 128,
|
||||
flags: 128,
|
||||
banner: null,
|
||||
accent_color: null,
|
||||
global_name: "random :3",
|
||||
avatar_decoration_data: null,
|
||||
collectibles: null,
|
||||
display_name_styles: null,
|
||||
banner_color: null,
|
||||
clan: null,
|
||||
primary_guild: null
|
||||
},
|
||||
pinned: false,
|
||||
mention_everyone: false,
|
||||
tts: false,
|
||||
message_reference: {
|
||||
type: 0,
|
||||
channel_id: "475599038536744962",
|
||||
message_id: "1488146734352826478",
|
||||
guild_id: "475599038536744960"
|
||||
},
|
||||
referenced_message: {
|
||||
type: 7,
|
||||
content: "",
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
timestamp: "2026-03-30T12:03:49.899000+00:00",
|
||||
edited_timestamp: null,
|
||||
flags: 0,
|
||||
components: [],
|
||||
id: "1488146734352826478",
|
||||
channel_id: "475599038536744962",
|
||||
author: {
|
||||
id: "1461677775554478161",
|
||||
username: "peasant321_76775",
|
||||
avatar: null,
|
||||
discriminator: "0",
|
||||
public_flags: 0,
|
||||
flags: 0,
|
||||
banner: null,
|
||||
accent_color: null,
|
||||
global_name: "PEASANT!!",
|
||||
avatar_decoration_data: null,
|
||||
collectibles: null,
|
||||
display_name_styles: null,
|
||||
banner_color: null,
|
||||
clan: null,
|
||||
primary_guild: null
|
||||
},
|
||||
pinned: false,
|
||||
mention_everyone: false,
|
||||
tts: false
|
||||
}
|
||||
},
|
||||
attachment_no_content: {
|
||||
id: "1124628646670389348",
|
||||
type: 0,
|
||||
|
|
|
|||
|
|
@ -95,12 +95,14 @@ WITH a (message_id, channel_id) AS (VALUES
|
|||
('1381212840957972480', '112760669178241024'),
|
||||
('1401760355339862066', '112760669178241024'),
|
||||
('1439351590262800565', '1438284564815548418'),
|
||||
('1404133238414376971', '112760669178241024'))
|
||||
('1404133238414376971', '112760669178241024'),
|
||||
('1162005314908999790', '1100319550446252084'))
|
||||
SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id;
|
||||
|
||||
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
|
||||
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1),
|
||||
('$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4', 'm.room.message', 'm.text', '1128118177155526666', 0, 0, 0),
|
||||
('$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg', 'm.room.message', 'm.text', '1162005314908999790', 0, 0, 1),
|
||||
('$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10', 'm.room.message', 'm.text', '1141619794500649020', 0, 0, 1),
|
||||
('$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY', 'm.room.message', 'm.text', '1141206225632112650', 0, 0, 1),
|
||||
('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1, 1),
|
||||
|
|
|
|||
|
|
@ -175,4 +175,5 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
|
|||
require("../src/web/routes/log-in-with-matrix.test")
|
||||
require("../src/web/routes/oauth.test")
|
||||
require("../src/web/routes/password.test")
|
||||
require("../src/agi/generator.test")
|
||||
})()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue