Skip to content

Commit

Permalink
feat: Add ctx.getNearestCommand using jaro-winkler algorithm (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
Camelik authored Dec 15, 2023
1 parent 416734d commit 45be98a
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 4 deletions.
18 changes: 14 additions & 4 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Commands } from "./commands.ts";
import { Context, NextFunction } from "./deps.deno.ts";
import { fuzzyMatch, JaroWinklerOptions } from "./jaro-winkler.ts";

export type CommandsFlavor<C extends Context = Context> = C & {
/**
Expand All @@ -10,13 +11,14 @@ export type CommandsFlavor<C extends Context = Context> = C & {
* @returns Promise with the result of the operations
*/
setMyCommands: (commands: Commands<C>) => Promise<true[]>;
getNearestCommand: (
commands: Commands<C>,
options?: Partial<JaroWinklerOptions>,
) => string | null;
};

export function commands<C extends Context>() {
return (
ctx: CommandsFlavor<C>,
next: NextFunction,
) => {
return (ctx: CommandsFlavor<C>, next: NextFunction) => {
ctx.setMyCommands = (commands) => {
if (!ctx.chat) {
throw new Error(
Expand All @@ -31,6 +33,14 @@ export function commands<C extends Context>() {
);
};

ctx.getNearestCommand = (commands, options) => {
if (ctx.msg?.text) {
const userInput = ctx.msg.text.substring(1);
return fuzzyMatch(userInput, commands, { ...options });
}
return null;
};

return next();
};
}
143 changes: 143 additions & 0 deletions src/jaro-winkler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { Commands } from "./commands.ts";
import { Context } from "./deps.deno.ts";

export function distance(s1: string, s2: string) {
if (s1.length === 0 || s2.length === 0) {
return 0;
}

const matchWindow = Math.floor(Math.max(s1.length, s2.length) / 2.0) - 1;
const matches1 = new Array(s1.length);
const matches2 = new Array(s2.length);
let m = 0; // number of matches
let t = 0; // number of transpositions
let i = 0; // index for string 1
let k = 0; // index for string 2

for (i = 0; i < s1.length; i++) {
// loop to find matched characters
const start = Math.max(0, i - matchWindow); // use the higher of the window diff
const end = Math.min(i + matchWindow + 1, s2.length); // use the min of the window and string 2 length

for (k = start; k < end; k++) {
// iterate second string index
if (matches2[k]) {
// if second string character already matched
continue;
}
if (s1[i] !== s2[k]) {
// characters don't match
continue;
}

// assume match if the above 2 checks don't continue
matches1[i] = true;
matches2[k] = true;
m++;
break;
}
}

// nothing matched
if (m === 0) {
return 0.0;
}

k = 0; // reset string 2 index
for (i = 0; i < s1.length; i++) {
// loop to find transpositions
if (!matches1[i]) {
// non-matching character
continue;
}
while (!matches2[k]) {
// move k index to the next match
k++;
}
if (s1[i] !== s2[k]) {
// if the characters don't match, increase transposition
// HtD: t is always less than the number of matches m, because transpositions are a subset of matches
t++;
}
k++; // iterate k index normally
}

// transpositions divided by 2
t /= 2.0;

return (m / s1.length + m / s2.length + (m - t) / m) / 3.0; // HtD: therefore, m - t > 0, and m - t < m
// HtD: => return value is between 0 and 1
}

export type JaroWinklerOptions = {
ignoreCase?: boolean;
similarityThreshold?: number;
};

type CommandSimilarity = {
command: string | null;
similarity: number;
};

// Computes the Winkler distance between two string -- intrepreted from:
// http://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance
// s1 is the first string to compare
// s2 is the second string to compare
// dj is the Jaro Distance (if you've already computed it), leave blank and the method handles it
// ignoreCase: if true strings are first converted to lower case before comparison
export function JaroWinklerDistance(
s1: string,
s2: string,
options: Partial<JaroWinklerOptions>,
) {
if (s1 === s2) {
return 1;
} else {
if (options.ignoreCase) {
s1 = s1.toLowerCase();
s2 = s2.toLowerCase();
}

const jaro = distance(s1, s2);
const p = 0.1; // default scaling factor
let l = 0; // length of the matching prefix
while (s1[l] === s2[l] && l < 4) {
l++;
}

// HtD: 1 - jaro >= 0
return jaro + l * p * (1 - jaro);
}
}

export function fuzzyMatch<C extends Context>(
userInput: string,
commands: Commands<C>,
options: Partial<JaroWinklerOptions>,
): string | null {
const defaultSimilarityThreshold = 0.85;
const similarityThreshold = options.similarityThreshold ||
defaultSimilarityThreshold;

const commandsSet = new Set(
commands
.toJSON()
.flatMap((item) => item.commands.map((command) => command.command)),
);

const bestMatch = Array.from(commandsSet).reduce(
(best: CommandSimilarity, command) => {
const similarity = JaroWinklerDistance(userInput, command, {
...options,
});
return similarity > best.similarity
? { command, similarity }
: best;
},
{ command: null, similarity: 0 },
);

return bestMatch.similarity > similarityThreshold
? bestMatch.command
: null;
}
8 changes: 8 additions & 0 deletions test/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,12 @@ describe("the commands function", () => {

assert(context.setMyCommands);
});
it("should install the getNearestCommand method on the context", () => {
const context = new Context(update, api, me) as CommandsFlavor<Context>;

const middleware = commands();
middleware(context, async () => {});

assert(context.getNearestCommand);
});
});
55 changes: 55 additions & 0 deletions test/jaroWrinkler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { distance } from "../src/jaro-winkler.ts";
import { fuzzyMatch, JaroWinklerDistance } from "../src/jaro-winkler.ts";
import { assertEquals, Context, describe, it } from "./deps.test.ts";
import { Commands } from "../src/mod.ts";

describe("The Jaro-Wrinkler Algorithm", () => {
it("should return value 0, because the empty string was given", () => {
assertEquals(distance("", ""), 0);
});

it("should return the correct similarity coefficient", () => {
assertEquals(distance("hello", "hola"), 0.6333333333333333);
});

it("should return value 1, because the strings are the same", () => {
assertEquals(JaroWinklerDistance("hello", "hello", {}), 1);
});

it("should return value 1, because case-sensitive is turn off", () => {
assertEquals(
JaroWinklerDistance("hello", "HELLO", { ignoreCase: true }),
1,
);
});

describe("Fuzzy Matching", () => {
it("should return the found command", () => {
const cmds = new Commands<Context>();

cmds.command(
"start",
"Starting",
).addToScope(
{ type: "all_private_chats" },
(ctx) => ctx.reply(`Hello, ${ctx.chat.first_name}!`),
);

assertEquals(fuzzyMatch("strt", cmds, {}), "start");
});

it("should return null because command doesn't exist", () => {
const cmds = new Commands<Context>();

cmds.command(
"start",
"Starting",
).addToScope(
{ type: "all_private_chats" },
(ctx) => ctx.reply(`Hello, ${ctx.chat.first_name}!`),
);

assertEquals(fuzzyMatch("xyz", cmds, {}), null);
});
});
});

0 comments on commit 45be98a

Please sign in to comment.