Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/conversation.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {
type CallbackQueryContext,
type CommandContext,
Composer,
Context,
type Filter,
type FilterQuery,
type GameQueryContext,
type HearsContext,
type MiddlewareFn,
type Middleware,
type ReactionContext,
type ReactionType,
type ReactionTypeEmoji,
Expand Down Expand Up @@ -196,6 +197,10 @@ export class Conversation<
OC extends Context = Context,
C extends Context = Context,
> {
private plugins: (
conversation: Conversation<OC, C>,
) => Middleware<C>[] | Promise<Middleware<C>[]>;

/** `true` if `external` is currently running, `false` otherwise */
private insideExternal = false;

Expand All @@ -220,9 +225,15 @@ export class Conversation<
private controls: ReplayControls,
private hydrate: (update: Update) => C,
private escape: ApplyContext<OC>,
private plugins: MiddlewareFn<C>,
plugins:
| Middleware<C>[]
| ((
conversation: Conversation<OC, C>,
) => Middleware<C>[] | Promise<Middleware<C>[]>),
private options: ConversationHandleOptions,
) {}
) {
this.plugins = Array.isArray(plugins) ? () => plugins : plugins;
}
/**
* Waits for a new update and returns the corresponding context object as
* soon as it arrives.
Expand Down Expand Up @@ -267,7 +278,8 @@ First return your data from `external` and then resume update handling using `wa

// run plugins
let pluginsCalledNext = false;
await this.plugins(ctx, () => {
const middleware = await this.plugins(this);
await new Composer(...middleware).middleware()(ctx, () => {
pluginsCalledNext = true;
return Promise.resolve();
});
Expand Down
46 changes: 35 additions & 11 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Conversation } from "./conversation.ts";
import {
Api,
type ApiClientOptions,
Composer,
Context,
HttpError,
type Middleware,
Expand All @@ -26,7 +25,11 @@ const internalCompletenessMarker = Symbol("conversations.completeness");
interface InternalState<OC extends Context, C extends Context> {
getMutableData(): ConversationData;
index: ConversationIndex<OC, C>;
defaultPlugins: Middleware<C>[];
defaultPlugins:
| Middleware<C>[]
| ((
conversation: Conversation<OC, C>,
) => Middleware<C>[] | Promise<Middleware<C>[]>);
exitHandler?(name: string): Promise<void>;
}

Expand Down Expand Up @@ -100,7 +103,11 @@ export interface ConversationOptions<OC extends Context, C extends Context> {
* each conversation will have the plugins installed that you specify
* explicitly when using {@link enterConversation}.
*/
plugins?: Middleware<C>[];
plugins?:
| Middleware<C>[]
| ((
conversation: Conversation<OC, C>,
) => Middleware<C>[] | Promise<Middleware<C>[]>);
/**
* Called when a conversation is entered via `ctx.conversation.enter`.
*
Expand Down Expand Up @@ -134,7 +141,7 @@ type ConversationIndex<OC extends Context, C extends Context> = Map<
>;
interface ConversationIndexEntry<OC extends Context, C extends Context> {
builder: ConversationBuilder<OC, C>;
plugins: Middleware<C>[];
plugins: (conversation: Conversation<OC, C>) => Promise<Middleware<C>[]>;
maxMillisecondsToWait: number | undefined;
parallel: boolean;
}
Expand Down Expand Up @@ -677,9 +684,10 @@ export type ConversationBuilder<OC extends Context, C extends Context> = (
* Configuration options for a conversation. These options can be passed to
* {@link createConversation} when installing the conversation.
*
* @typeParam OC The type of context object of the outside middleware
* @typeParam C The type of context object used inside this conversation
*/
export interface ConversationConfig<C extends Context> {
export interface ConversationConfig<OC extends Context, C extends Context> {
/**
* Identifier of the conversation. The identifier can be used to enter or
* exit conversations from middleware.
Expand Down Expand Up @@ -738,7 +746,11 @@ export interface ConversationConfig<C extends Context> {
* that you need to use the custom context type used inside the
* conversation, not the custom context type used in the outside middleware.
*/
plugins?: Middleware<C>[];
plugins?:
| Middleware<C>[]
| ((
conversation: Conversation<OC, C>,
) => Middleware<C>[] | Promise<Middleware<C>[]>);
/**
* Specifies a default timeout for all wait calls inside the conversation.
*
Expand Down Expand Up @@ -831,7 +843,7 @@ export interface ConversationConfig<C extends Context> {
*/
export function createConversation<OC extends Context, C extends Context>(
builder: ConversationBuilder<OC, C>,
options?: string | ConversationConfig<C>,
options?: string | ConversationConfig<OC, C>,
): MiddlewareFn<ConversationFlavor<OC>> {
const {
id = builder.name,
Expand All @@ -854,7 +866,16 @@ export function createConversation<OC extends Context, C extends Context>(
if (index.has(id)) {
throw new Error(`Duplicate conversation identifier '${id}'!`);
}
const combinedPlugins = [...defaultPlugins, ...plugins];
const defaultPluginsFunc = typeof defaultPlugins === "function"
? defaultPlugins
: () => defaultPlugins;
const pluginsFunc = typeof plugins === "function"
? plugins
: () => plugins;
const combinedPlugins = async (conversation: Conversation<OC, C>) => [
...await defaultPluginsFunc(conversation),
...await pluginsFunc(conversation),
];
index.set(id, {
builder,
plugins: combinedPlugins,
Expand Down Expand Up @@ -1061,7 +1082,11 @@ export interface ResumeOptions<OC extends Context, C extends Context> {
/** A context object from the outside middleware to use in `external` */
ctx?: OC;
/** An array of plugins to run for newly created context objects */
plugins?: Middleware<C>[];
plugins?:
| Middleware<C>[]
| ((
conversation: Conversation<OC, C>,
) => Middleware<C>[] | Promise<Middleware<C>[]>);
/** A callback function to run if `conversation.halt` is called */
onHalt?(): void | Promise<void>;
/** A default wait timeout */
Expand Down Expand Up @@ -1101,12 +1126,11 @@ export async function resumeConversation<OC extends Context, C extends Context>(
maxMillisecondsToWait,
parallel,
} = options ?? {};
const middleware = new Composer(...plugins).middleware();
// deno-lint-ignore no-explicit-any
const escape = (fn: (ctx: OC) => any) => fn(ctx);
const engine = new ReplayEngine(async (controls) => {
const hydrate = hydrateContext<C>(controls, api, me);
const convo = new Conversation(controls, hydrate, escape, middleware, {
const convo = new Conversation(controls, hydrate, escape, plugins, {
onHalt,
maxMillisecondsToWait,
parallel,
Expand Down
37 changes: 37 additions & 0 deletions test/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,43 @@ describe("Conversation", () => {
assert(fifth.status === "complete");
assertEquals(i, 2);
});
it("should wait with Promise.all", async () => {
const ctx = mkctx();
let i = 0;
async function convo(conversation: Convo) {
await Promise.all([
conversation.wait(),
conversation.wait(),
conversation.wait(),
conversation.wait(),
]);
i++;
}
const first = await enterConversation(convo, ctx);
assertEquals(first.status, "handled");
assert(first.status === "handled");
assertEquals(i, 0);
const copy = structuredClone(first);
const second = await resumeConversation(convo, ctx, copy);
assertEquals(second.status, "handled");
assert(second.status === "handled");
assertEquals(i, 0);
const otherCopy = { ...structuredClone(second), args: first.args };
const third = await resumeConversation(convo, ctx, otherCopy);
assertEquals(third.status, "handled");
assert(third.status === "handled");
assertEquals(i, 0);
const thirdCopy = { ...structuredClone(third), args: first.args };
const fourth = await resumeConversation(convo, ctx, thirdCopy);
assertEquals(fourth.status, "handled");
assert(fourth.status === "handled");
assertEquals(i, 0);
const fourthCopy = { ...structuredClone(fourth), args: first.args };
const fifth = await resumeConversation(convo, ctx, fourthCopy);
assertEquals(fifth.status, "complete");
assert(fifth.status === "complete");
assertEquals(i, 1);
});
it("should skip", async () => {
let i = 0;
let j = 0;
Expand Down
88 changes: 87 additions & 1 deletion test/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ describe("createConversation", () => {
it("should support plugins", async () => {
const mw = new Composer<TestContext>();
let i = 0;
let seq = "";

type PluginContext = TestContext & {
phi: number;
Expand All @@ -227,31 +228,112 @@ describe("createConversation", () => {
mw.use(
conversations({
plugins: [async (ctx, next) => {
seq += "a";
Object.assign(ctx, { phi: 0.5 * (1 + Math.sqrt(5)) });
await next();
seq += "b";
}],
}),
createConversation(async (convo, ctx: PluginContext) => {
seq += "c";
assertEquals(ctx.prop, 42);
ctx = await convo.wait();
seq += "d";
assertEquals(ctx.prop, 42);
assertEquals(Math.round(ctx.phi), 2);
i++;
}, {
id: "convo",
plugins: [async (ctx, next) => {
seq += "e";
Object.assign(ctx, { prop: 0 });
await next();
seq += "f";
}, async (ctx, next) => {
seq += "g";
Object.assign(ctx, { prop: 42 });
await next();
seq += "h";
}],
}),
(ctx) => ctx.conversation.enter("convo"),
async (ctx) => {
seq += "i";
await ctx.conversation.enter("convo");
seq += "j";
},
);
seq += "k";
await mw.middleware()(mkctx(), next);
seq += "l";
await mw.middleware()(mkctx(), next);
seq += "m";
assertEquals(i, 1);
assertEquals(seq, "kiaeghfbcjlaeghfbcaeghfbdm");
});
it("should support plugins that have access to the conversation handle", async () => {
const mw = new Composer<TestContext>();
let i = 0;
let seq = "";
let kill = false;

type PluginContext = TestContext & {
phi: number;
prop: number;
};
mw.use(
conversations({
plugins: async (conversation) => {
seq += "0";
if (kill) await conversation.halt();
return [async (ctx, next) => {
seq += "a";
Object.assign(ctx, { phi: 0.5 * (1 + Math.sqrt(5)) });
await next();
seq += "b";
}];
},
}),
createConversation(async (convo, ctx: PluginContext) => {
seq += "c";
assertEquals(ctx.prop, 42);
ctx = await convo.wait();
seq += "d";
assertEquals(ctx.prop, 42);
assertEquals(Math.round(ctx.phi), 2);
i++;
}, {
id: "convo",
plugins: () => {
seq += "1";
return [async (ctx, next) => {
seq += "e";
Object.assign(ctx, { prop: 0 });
await next();
seq += "f";
}, async (ctx, next) => {
seq += "g";
Object.assign(ctx, { prop: 42 });
await next();
seq += "h";
}];
},
}),
async (ctx) => {
seq += "i";
await ctx.conversation.enter("convo");
seq += "j";
},
);
seq += "k";
await mw.middleware()(mkctx(), next);
seq += "l";
await mw.middleware()(mkctx(), next);
seq += "m";
kill = true;
await mw.middleware()(mkctx(), next);
seq += "n";
assertEquals(i, 1);
assertEquals(seq, "ki01aeghfbcjl01aeghfbc01aeghfbdmi0jn");
});
it("should halt the conversation upon default wait timeout", async () => {
const onExit = spy(() => {});
Expand Down Expand Up @@ -859,6 +941,7 @@ describe("createConversation", () => {

let i = 0;
let p: Promise<unknown> | undefined;
const e = Promise.withResolvers<void>();
mw.use(
conversations({
storage: {
Expand All @@ -868,14 +951,17 @@ describe("createConversation", () => {
}),
createConversation(async (c) => {
i++;
e.resolve();
await c.wait();
}, "convo"),
(ctx) => {
console.log("mw");
p = assertRejects(() => ctx.conversation.enter("convo"))
.then(() => i++);
},
);
await mw.middleware()(ctx, next);
await e.promise;
assertEquals(i, 1);
await p;
assertEquals(i, 2);
Expand Down