Compare commits

..

17 commits

Author SHA1 Message Date
91bce76fc8 Use HTML to strip per-message profile fallback 2026-03-29 15:41:23 +13:00
nemesio65
12f4103870 d2m: Create voice channels as call rooms 2026-03-28 11:46:08 +13:00
e28eac6bfa Update domino 2026-03-28 11:45:51 +13:00
857fb7583b v3.5 2026-03-27 19:20:04 +13:00
59012d9613 Fix pinning random messages 2026-03-27 19:13:03 +13:00
953b3e7741 Attach message to error
Apparently this was causing detached logs, so just stop those
complaints if the error isn't being bubbled
2026-03-26 00:16:30 +13:00
8c023cc936 Add ping() function to REPL 2026-03-25 16:24:07 +13:00
e9fe820666 Registration changes should be instant now 2026-03-25 16:22:37 +13:00
f742d8572a MSC4144 minor changes for merge 2026-03-25 03:10:54 +00:00
Bea
8224ed5341 feat(discord): show per-message profile info in matrix info command 2026-03-25 03:10:54 +00:00
Bea
0b513b7ee0 fix(m2d): implement MSC4144 avatar clearing algorithm
- Empty string "" -> undefined (Discord uses default avatar)
- Valid MXC URI -> convert to public URL
- Omitted/null -> keep member avatar
2026-03-25 03:10:54 +00:00
Bea
07ec9832b2 fix(m2d): only use unstable com.beeper.per_message_profile prefix 2026-03-25 03:10:54 +00:00
Bea
a8b7d64e91 feat(m2d): strip per-message profile fallbacks from message content
Remove data-mx-profile-fallback elements from formatted_body and
displayname prefix from plain body when per-message profile is used.
2026-03-25 03:10:54 +00:00
Bea
41692b11ff feat(m2d): support MSC4144 per-message profiles
Override webhook username and avatar_url from m.per_message_profile
(and unstable com.beeper.per_message_profile) when present.
The stable key takes priority over the unstable prefix.
2026-03-25 03:10:54 +00:00
d8c0a947f2 Automatically reload registration 2026-03-25 15:39:26 +13:00
5c9e569a2a Support channel follow messages 2026-03-25 15:29:18 +13:00
201814e9f4 Update dependencies 2026-03-23 21:22:33 +13:00
35 changed files with 1419 additions and 60 deletions

View file

@ -1,16 +0,0 @@
root = true
[*]
indent_style = tab
tab_width = 3
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.json]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false

View file

@ -1 +0,0 @@
*

View file

@ -1,5 +0,0 @@
{
"recommendations": [
"editorconfig.editorconfig"
]
}

48
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "out-of-your-element",
"version": "3.4.0",
"version": "3.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "out-of-your-element",
"version": "3.4.0",
"version": "3.5.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1",
@ -30,7 +30,7 @@
"enquirer": "^2.4.1",
"entities": "^5.0.0",
"get-relative-path": "^1.0.2",
"h3": "^1.15.1",
"h3": "^1.15.10",
"heatsync": "^2.7.2",
"htmx.org": "^2.0.4",
"lru-cache": "^11.0.2",
@ -276,9 +276,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"license": "MIT",
"optional": true,
"dependencies": {
@ -1163,9 +1163,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1488,9 +1488,9 @@
"license": "MIT"
},
"node_modules/domino": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz",
"integrity": "sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/domino/-/domino-2.1.7.tgz",
"integrity": "sha512-3rcXhx0ixJV2nj8J0tljzejTF73A35LVVdnTQu79UAqTBFEgYPMgGtykMuu/BDqaOZphATku1ddRUn/RtqUHYQ==",
"license": "BSD-2-Clause"
},
"node_modules/emoji-regex": {
@ -1587,9 +1587,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@ -1617,9 +1617,9 @@
"license": "MIT"
},
"node_modules/fullstore": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/fullstore/-/fullstore-4.0.0.tgz",
"integrity": "sha512-Y9hN79Q1CFU8akjGnTZoBnTzlA/o8wmtBijJOI8dKCmdC7GLX7OekpLxmbaeRetTOi4OdFGjfsg4c5dxP3jgPw==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/fullstore/-/fullstore-4.0.2.tgz",
"integrity": "sha512-syOev4kA0lZy4VkfBJZ99ZL4cIiSgiKt0G8SpP0kla1tpM1c+V/jBOVY/OqqGtR2XLVcM83SjFPFC3R2YIwqjQ==",
"dev": true,
"license": "MIT",
"engines": {
@ -1688,9 +1688,9 @@
}
},
"node_modules/h3": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.6.tgz",
"integrity": "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ==",
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz",
"integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==",
"license": "MIT",
"dependencies": {
"cookie-es": "^1.2.2",
@ -1937,9 +1937,9 @@
"license": "MIT"
},
"node_modules/json-with-bigint": {
"version": "3.5.7",
"resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz",
"integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==",
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz",
"integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==",
"dev": true,
"license": "MIT"
},

View file

@ -1,6 +1,6 @@
{
"name": "out-of-your-element",
"version": "3.4.0",
"version": "3.5.0",
"description": "A bridge between Matrix and Discord",
"main": "index.js",
"repository": {
@ -39,7 +39,7 @@
"enquirer": "^2.4.1",
"entities": "^5.0.0",
"get-relative-path": "^1.0.2",
"h3": "^1.15.1",
"h3": "^1.15.10",
"heatsync": "^2.7.2",
"htmx.org": "^2.0.4",
"lru-cache": "^11.0.2",

View file

@ -13,5 +13,5 @@ const {prompt} = require("enquirer")
reg.ooye.web_password = passwordResponse.web_password
writeRegistration(reg)
console.log("Saved. Restart Out Of Your Element to apply this change.")
console.log("Saved. This change should be applied instantly.")
})()

333
src/agi/elizabot.js Normal file
View 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
View 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
View 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
View 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
View 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

View file

@ -193,6 +193,16 @@ async function channelToKState(channel, guild, di) {
// Don't overwrite room topic if the topic has been customised
if (hasCustomTopic) delete channelKState["m.room.topic/"]
// Make voice channels be a Matrix voice room (MSC3417)
if (channel.type === DiscordTypes.ChannelType.GuildVoice) {
creationContent.type = "org.matrix.msc3417.call"
channelKState["org.matrix.msc3401.call/"] = {
"m.intent": "m.room",
"m.type": "m.voice",
"m.name": customName || channel.name
}
}
// Don't add a space parent if it's self service
// (The person setting up self-service has already put it in their preferred space to be able to get this far.)
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()

View file

@ -190,6 +190,17 @@ test("channel2room: read-only discord channel", async t => {
t.equal(api.getCalled(), 2)
})
test("channel2room: voice channel", async t => {
const api = mockAPI(t)
const state = kstateStripConditionals(await channelToKState(testData.channel.voice, testData.guild.general, {api}).then(x => x.channelKState))
t.equal(state["m.room.create/"].type, "org.matrix.msc3417.call")
t.deepEqual(state["org.matrix.msc3401.call/"], {
"m.intent": "m.room",
"m.name": "🍞丨[8user] Piece",
"m.type": "m.voice"
})
})
test("convertNameAndTopic: custom name and topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),

View file

@ -34,7 +34,10 @@ async function emojisToState(emojis, guild) {
if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit.
return
}
console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`)
e["emoji"] = {
name: emoji.name,
id: emoji.id
}
throw e
})
))

View file

@ -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
}

View file

@ -357,6 +357,17 @@ async function messageToEvent(message, guild, options = {}, di) {
}]
}
if (message.type === DiscordTypes.MessageType.ChannelFollowAdd) {
return [{
$type: "m.room.message",
msgtype: "m.emote",
body: `set this room to receive announcements from ${message.content}`,
format: "org.matrix.custom.html",
formatted_body: tag`set this room to receive announcements from <strong>${message.content}</strong>`,
"m.mentions": {}
}]
}
let isInteraction = (message.type === DiscordTypes.MessageType.ChatInputCommand || message.type === DiscordTypes.MessageType.ContextMenuCommand) && message.interaction && "name" in message.interaction
let isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading)

View file

@ -1142,6 +1142,19 @@ test("message2event: type 4 channel name change", async t => {
}])
})
test("message2event: type 12 channel follow add", async t => {
const events = await messageToEvent(data.special_message.channel_follow_add, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.emote",
body: "set this room to receive announcements from PluralKit #downtime",
format: "org.matrix.custom.html",
formatted_body: "set this room to receive announcements from <strong>PluralKit #downtime</strong>",
"m.mentions": {}
}])
})
test("message2event: thread start message reference", async t => {
const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, {
api: {

View file

@ -22,7 +22,7 @@ function pinsToList(pins, kstate) {
/** @type {string[]} */
const result = []
for (const pin of pins.items) {
const eventID = select("event_message", "event_id", {message_id: pin.message.id, part: 0}).pluck().get()
const eventID = select("event_message", "event_id", {message_id: pin.message.id}, "ORDER BY part ASC").pluck().get()
if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID)
}
result.reverse()

View file

@ -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()
@ -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!

View 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
View file

@ -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

View file

@ -62,7 +62,29 @@ async function _interact({guild_id, data}, {api}) {
.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())
const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get()
const name = matrixMember?.displayname || event.sender
let name = matrixMember?.displayname || event.sender
let avatar = utils.getPublicUrlForMxc(matrixMember?.avatar_url)
// Check for per-message profile
const perMessageProfile = event.content?.["com.beeper.per_message_profile"]
let profileNote = ""
if (perMessageProfile) {
if (perMessageProfile.displayname) {
name = perMessageProfile.displayname
}
if ("avatar_url" in perMessageProfile) {
if (perMessageProfile.avatar_url) {
// use provided avatar_url
avatar = utils.getPublicUrlForMxc(perMessageProfile.avatar_url)
} else if (perMessageProfile.avatar_url === "") {
// empty string avatar_url clears the avatar
avatar = undefined
}
// else, omitted/null falls back to member avatar
}
profileNote = "Sent with a per-message profile.\n"
}
return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
@ -70,9 +92,9 @@ async function _interact({guild_id, data}, {api}) {
author: {
name,
url: `https://matrix.to/#/${event.sender}`,
icon_url: utils.getPublicUrlForMxc(matrixMember?.avatar_url)
icon_url: avatar
},
description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix →](<https://matrix.to/#/${message.room_id}/${message.event_id}?${via}>)\n\n**User ID**: [${event.sender}](<https://matrix.to/#/${event.sender}>)`,
description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix →](<https://matrix.to/#/${message.room_id}/${message.event_id}?${via}>)\n\n${profileNote}**User ID**: [${event.sender}](<https://matrix.to/#/${event.sender}>)`,
color: 0x0dbd8b,
fields: [{
name: "In Channels",

View file

@ -85,3 +85,118 @@ test("matrix info: shows info for matrix source message", async t => {
)
t.equal(called, 1)
})
test("matrix info: shows username for per-message profile", async t => {
let called = 0
const msg = await _interact({
data: {
target_id: "1128118177155526666",
resolved: {
messages: {
"1141501302736695316": data.message.simple_reply_to_matrix_user
}
}
},
guild_id: "112760669178241024"
}, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
return {
event_id: eventID,
room_id: roomID,
type: "m.room.message",
content: {
msgtype: "m.text",
body: "master chief: i like the halo",
format: "org.matrix.custom.html",
formatted_body: "<strong>master chief: </strong>i like the halo",
"com.beeper.per_message_profile": {
has_fallback: true,
displayname: "master chief",
avatar_url: ""
}
},
sender: "@cadence:cadence.moe"
}
},
async getJoinedMembers(roomID) {
return {
joined: {}
}
},
async getStateEventOuter(roomID, type, key) {
return {
content: {
room_version: "11"
}
}
},
async getStateEvent(roomID, type, key) {
return {}
}
}
})
t.equal(msg.data.embeds[0].author.name, "master chief")
t.match(msg.data.embeds[0].description, "Sent with a per-message profile")
t.equal(called, 1)
})
test("matrix info: shows avatar for per-message profile", async t => {
let called = 0
const msg = await _interact({
data: {
target_id: "1128118177155526666",
resolved: {
messages: {
"1141501302736695316": data.message.simple_reply_to_matrix_user
}
}
},
guild_id: "112760669178241024"
}, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
return {
event_id: eventID,
room_id: roomID,
type: "m.room.message",
content: {
msgtype: "m.text",
body: "?",
format: "org.matrix.custom.html",
formatted_body: "?",
"com.beeper.per_message_profile": {
avatar_url: "mxc://cadence.moe/HXfFuougamkURPPMflTJRxGc"
}
},
sender: "@mystery:cadence.moe"
}
},
async getJoinedMembers(roomID) {
return {
joined: {}
}
},
async getStateEventOuter(roomID, type, key) {
return {
content: {
room_version: "11"
}
}
},
async getStateEvent(roomID, type, key) {
return {}
}
}
})
t.equal(msg.data.embeds[0].author.name, "@mystery:cadence.moe")
t.equal(msg.data.embeds[0].author.icon_url, "https://bridge.example.org/download/matrix/cadence.moe/HXfFuougamkURPPMflTJRxGc")
t.match(msg.data.embeds[0].description, "Sent with a per-message profile")
t.equal(called, 1)
})

View file

@ -13,7 +13,7 @@ async function updatePins(pins, prev) {
const diff = diffPins.diffPins(pins, prev)
for (const [event_id, added] of diff) {
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
.select("reference_channel_id", "message_id").get()
.select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get()
if (!row) continue
if (added) {
discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix")

View file

@ -550,13 +550,30 @@ async function eventToMessage(event, guild, channel, di) {
/** @type {string[]} */
let messageIDsToEdit = []
let replyLine = ""
// Extract a basic display name from the sender
const match = event.sender.match(/^@(.*?):/)
if (match) displayName = match[1]
// Try to extract an accurate display name and avatar URL from the member event
const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api)
if (member.displayname) displayName = member.displayname
if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url)
// MSC4144: Override display name and avatar from per-message profile if present
const perMessageProfile = event.content["com.beeper.per_message_profile"]
if (perMessageProfile?.displayname) displayName = perMessageProfile.displayname
if (perMessageProfile && "avatar_url" in perMessageProfile) {
if (perMessageProfile.avatar_url) {
// use provided avatar_url
avatarURL = mxUtils.getPublicUrlForMxc(perMessageProfile.avatar_url)
} else if (perMessageProfile.avatar_url === "") {
// empty string avatar_url clears the avatar
avatarURL = undefined
}
// else, omitted/null falls back to member avatar
}
// If the display name is too long to be put into the webhook (80 characters is the maximum),
// put the excess characters into displayNameRunoff, later to be put at the top of the message
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
@ -859,8 +876,9 @@ async function eventToMessage(event, guild, channel, di) {
const doc = domino.createDocument(
// DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element.
'<x-turndown id="turndown-root">' + input + '</x-turndown>'
);
const root = doc.getElementById("turndown-root");
)
const root = doc.getElementById("turndown-root")
assert(root)
async function forEachNode(event, node) {
for (; node; node = node.nextSibling) {
// Check written mentions
@ -913,6 +931,7 @@ async function eventToMessage(event, guild, channel, di) {
}
}
await forEachNode(event, root)
if (perMessageProfile?.has_fallback) root.querySelectorAll("[data-mx-profile-fallback]").forEach(x => x.remove())
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
// First we need to determine which emojis are at the end.
@ -944,6 +963,10 @@ async function eventToMessage(event, guild, channel, di) {
} else {
// Looks like we're using the plaintext body!
content = event.content.body
if (perMessageProfile?.has_fallback && perMessageProfile.displayname && content.startsWith(perMessageProfile.displayname + ": ")) {
// Strip the display name prefix fallback added for clients that don't support per-message profiles
content = content.slice(perMessageProfile.displayname.length + 2)
}
if (event.content.msgtype === "m.emote") {
content = `* ${displayName} ${content}`

View file

@ -5526,6 +5526,141 @@ test("event2message: known and unknown emojis in the end are used for sprite she
)
})
test("event2message: com.beeper.per_message_profile overrides displayname and avatar_url", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "hello from unstable profile",
"com.beeper.per_message_profile": {
id: "custom-id",
displayname: "Unstable Name",
avatar_url: "mxc://maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo"
}
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "Unstable Name",
content: "hello from unstable profile",
avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo",
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: com.beeper.per_message_profile empty avatar_url clears avatar", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "hello with cleared avatar",
"com.beeper.per_message_profile": {
id: "no-avatar",
displayname: "No Avatar User",
avatar_url: ""
}
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "No Avatar User",
content: "hello with cleared avatar",
avatar_url: undefined,
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: data-mx-profile-fallback element is stripped from formatted_body when per-message profile is present", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "Tidus Herboren: one more test",
format: "org.matrix.custom.html",
formatted_body: "<strong data-mx-profile-fallback>Tidus Herboren: </strong>one more test",
"com.beeper.per_message_profile": {
id: "tidus",
displayname: "Tidus Herboren",
avatar_url: "mxc://maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo",
has_fallback: true
}
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "Tidus Herboren",
content: "one more test",
avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo",
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: displayname prefix is stripped from plain body when per-message profile has_fallback", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "Tidus Herboren: one more test",
"com.beeper.per_message_profile": {
id: "tidus",
displayname: "Tidus Herboren",
has_fallback: true
}
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "Tidus Herboren",
content: "one more test",
avatar_url: undefined,
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: all unknown chess emojis are used for sprite sheet", async t => {
t.deepEqual(
await eventToMessage({

View file

@ -78,6 +78,11 @@ function readRegistration() {
/** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore
let reg = readRegistration()
fs.watch(registrationFilePath, {persistent: false}, () => {
let newReg = readRegistration()
Object.assign(reg, newReg)
})
module.exports.registrationFilePath = registrationFilePath
module.exports.readRegistration = readRegistration
module.exports.getTemplateRegistration = getTemplateRegistration

View file

@ -23,10 +23,26 @@ const setPresence = sync.require("./d2m/actions/set-presence")
const channelWebhook = sync.require("./m2d/actions/channel-webhook")
const guildID = "112760669178241024"
async function ping() {
const result = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
if (result.ok) {
return "Ping OK. The homeserver and OOYE are talking to each other fine."
} else {
if (typeof result.root === "string") {
var msg = `Cannot reach homeserver: ${result.root}`
} else if (result.root.error) {
var msg = `Homeserver said: [${result.status}] ${result.root.error}`
} else {
var msg = `Homeserver said: [${result.status}] ${JSON.stringify(result.root)}`
}
return msg + "\nMatrix->Discord won't work until you fix this.\nIf your installation has recently changed, consider `npm run setup` again."
}
}
if (process.stdin.isTTY) {
setImmediate(() => {
if (!passthrough.repl) {
const cli = repl.start({ prompt: "", eval: customEval, writer: s => s })
const cli = repl.start({prompt: "", eval: customEval, writer: s => s})
Object.assign(cli.context, passthrough)
passthrough.repl = cli
}

View 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
View 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")

View file

@ -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
View 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)
}))

View file

@ -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")

View file

@ -19,6 +19,26 @@ module.exports = {
default_thread_rate_limit_per_user: 0,
guild_id: "112760669178241024"
},
voice: {
voice_background_display: null,
version: 1774469910848,
user_limit: 0,
type: 2,
theme_color: null,
status: null,
rtc_region: null,
rate_limit_per_user: 0,
position: 0,
permission_overwrites: [],
parent_id: "805261291908104252",
nsfw: false,
name: "🍞丨[8user] Piece",
last_message_id: "1459912691098325137",
id: "1036840786093953084",
flags: 0,
bitrate: 256000,
guild_id: "112760669178241024"
},
updates: {
type: 0,
topic: "Updates and release announcements for Out Of Your Element.",
@ -6170,6 +6190,37 @@ module.exports = {
components: [],
position: 12
},
channel_follow_add: {
type: 12,
content: "PluralKit #downtime",
attachments: [],
embeds: [],
timestamp: "2026-03-24T23:16:04.097Z",
edited_timestamp: null,
flags: 0,
components: [],
id: "1486141581047369888",
channel_id: "1451125453082591314",
author: {
id: "154058479798059009",
username: "exaptations",
discriminator: "0",
avatar: "57b5cfe09a48a5902f2eb8fa65bb1b80",
bot: false,
flags: 0,
globalName: "Exa",
},
pinned: false,
mentions: [],
mention_roles: [],
mention_everyone: false,
tts: false,
message_reference: {
type: 0,
channel_id: "1015204661701124206",
guild_id: "466707357099884544"
}
},
updated_to_start_thread_from_here: {
t: "MESSAGE_UPDATE",
s: 19,

View file

@ -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")
})()