@khester/create-dynamics-app 2.7.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/artifacts/registry.d.ts.map +1 -1
- package/dist/artifacts/registry.js +44 -0
- package/dist/artifacts/registry.js.map +1 -1
- package/dist/artifacts/types.d.ts +1 -1
- package/dist/artifacts/types.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/scaffold.d.ts +1 -0
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +2 -0
- package/dist/scaffold.js.map +1 -1
- package/package.json +1 -1
- package/templates/teams-app/_variants/tab.basic/appPackage/manifest.template.json +2 -2
- package/templates/teams-app/_variants/tab.dashboard/appPackage/manifest.template.json +2 -2
- package/templates/teams-app/_variants/tab.sso/appPackage/manifest.template.json +2 -2
- package/templates/teams-bot/.env.example +18 -0
- package/templates/teams-bot/README.md +52 -0
- package/templates/teams-bot/_variants/bot.echo/appPackage/manifest.template.json +25 -0
- package/templates/teams-bot/_variants/bot.echo/src/bot/TeamsBot.ts +142 -0
- package/templates/teams-bot/_variants/bot.echo/src/bot/welcomeCard.ts +134 -0
- package/templates/teams-bot/_variants/bot.echo/src/index.ts +59 -0
- package/templates/teams-bot/_variants/bot.echo/src/mock/mockMode.ts +104 -0
- package/templates/teams-bot/_variants/bot.notification/appPackage/manifest.template.json +25 -0
- package/templates/teams-bot/_variants/bot.notification/src/bot/NotificationBot.ts +54 -0
- package/templates/teams-bot/_variants/bot.notification/src/index.ts +136 -0
- package/templates/teams-bot/_variants/bot.notification/src/mock/mockMode.ts +55 -0
- package/templates/teams-bot/_variants/bot.notification/src/storage/conversationStore.ts +84 -0
- package/templates/teams-bot/appPackage/color.png +0 -0
- package/templates/teams-bot/appPackage/outline.png +0 -0
- package/templates/teams-bot/gitignore +8 -0
- package/templates/teams-bot/nodemon.json +6 -0
- package/templates/teams-bot/package.json +24 -0
- package/templates/teams-bot/src/adapters/botAdapter.ts +45 -0
- package/templates/teams-bot/src/config.ts +34 -0
- package/templates/teams-bot/tsconfig.json +17 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { CardFactory, Attachment } from 'botbuilder';
|
|
2
|
+
|
|
3
|
+
export function createWelcomeCard(userName: string): Attachment {
|
|
4
|
+
const card = {
|
|
5
|
+
type: 'AdaptiveCard',
|
|
6
|
+
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
|
7
|
+
version: '1.4',
|
|
8
|
+
body: [
|
|
9
|
+
{
|
|
10
|
+
type: 'TextBlock',
|
|
11
|
+
size: 'Large',
|
|
12
|
+
weight: 'Bolder',
|
|
13
|
+
text: `Welcome, ${userName}!`,
|
|
14
|
+
wrap: true,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
type: 'TextBlock',
|
|
18
|
+
text: "I'm a Teams bot built with Bot Framework. Here's what I can do:",
|
|
19
|
+
wrap: true,
|
|
20
|
+
spacing: 'Medium',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
type: 'FactSet',
|
|
24
|
+
facts: [
|
|
25
|
+
{ title: 'Echo', value: 'I repeat what you say' },
|
|
26
|
+
{ title: 'Help', value: 'Type "help" for commands' },
|
|
27
|
+
{ title: 'About', value: 'Type "about" for info' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
type: 'TextBlock',
|
|
32
|
+
text: 'Try sending me a message!',
|
|
33
|
+
wrap: true,
|
|
34
|
+
spacing: 'Medium',
|
|
35
|
+
color: 'Accent',
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
actions: [
|
|
39
|
+
{
|
|
40
|
+
type: 'Action.Submit',
|
|
41
|
+
title: 'Say Hello',
|
|
42
|
+
data: { action: 'hello' },
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return CardFactory.adaptiveCard(card);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createHelpCard(): Attachment {
|
|
51
|
+
const card = {
|
|
52
|
+
type: 'AdaptiveCard',
|
|
53
|
+
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
|
54
|
+
version: '1.4',
|
|
55
|
+
body: [
|
|
56
|
+
{
|
|
57
|
+
type: 'TextBlock',
|
|
58
|
+
size: 'Medium',
|
|
59
|
+
weight: 'Bolder',
|
|
60
|
+
text: 'Available Commands',
|
|
61
|
+
wrap: true,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
type: 'Container',
|
|
65
|
+
items: [
|
|
66
|
+
{
|
|
67
|
+
type: 'TextBlock',
|
|
68
|
+
text: '**help** - Show this help message',
|
|
69
|
+
wrap: true,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
type: 'TextBlock',
|
|
73
|
+
text: '**about** - Learn about this bot',
|
|
74
|
+
wrap: true,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: 'TextBlock',
|
|
78
|
+
text: '**echo [message]** - Echo your message',
|
|
79
|
+
wrap: true,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
type: 'TextBlock',
|
|
83
|
+
text: 'Or just type anything and I will echo it back!',
|
|
84
|
+
wrap: true,
|
|
85
|
+
spacing: 'Medium',
|
|
86
|
+
isSubtle: true,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return CardFactory.adaptiveCard(card);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createAboutCard(): Attachment {
|
|
97
|
+
const card = {
|
|
98
|
+
type: 'AdaptiveCard',
|
|
99
|
+
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
|
100
|
+
version: '1.4',
|
|
101
|
+
body: [
|
|
102
|
+
{
|
|
103
|
+
type: 'TextBlock',
|
|
104
|
+
size: 'Medium',
|
|
105
|
+
weight: 'Bolder',
|
|
106
|
+
text: 'About This Bot',
|
|
107
|
+
wrap: true,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
type: 'FactSet',
|
|
111
|
+
facts: [
|
|
112
|
+
{ title: 'Name', value: '{{projectName}}' },
|
|
113
|
+
{ title: 'Framework', value: 'Bot Framework SDK v4' },
|
|
114
|
+
{ title: 'Scaffold', value: 'create-dynamics-app (teams-bot)' },
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
type: 'TextBlock',
|
|
119
|
+
text: 'This bot demonstrates the fundamentals of building a Teams bot using the Bot Framework SDK.',
|
|
120
|
+
wrap: true,
|
|
121
|
+
spacing: 'Medium',
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
actions: [
|
|
125
|
+
{
|
|
126
|
+
type: 'Action.OpenUrl',
|
|
127
|
+
title: 'View Documentation',
|
|
128
|
+
url: 'https://learn.microsoft.com/en-us/microsoftteams/platform/bots/what-are-bots',
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return CardFactory.adaptiveCard(card);
|
|
134
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import express, { Request, Response } from 'express';
|
|
2
|
+
import { config, validateConfig } from './config';
|
|
3
|
+
import { createBotAdapter } from './adapters/botAdapter';
|
|
4
|
+
import { TeamsBot } from './bot/TeamsBot';
|
|
5
|
+
import { startInteractiveTest } from './mock/mockMode';
|
|
6
|
+
|
|
7
|
+
validateConfig();
|
|
8
|
+
|
|
9
|
+
// Interactive mock mode: console chat loop, no server / no Azure.
|
|
10
|
+
if (process.argv.includes('--interactive')) {
|
|
11
|
+
startInteractiveTest();
|
|
12
|
+
} else {
|
|
13
|
+
const app = express();
|
|
14
|
+
app.use(express.json());
|
|
15
|
+
|
|
16
|
+
const adapter = createBotAdapter();
|
|
17
|
+
const bot = new TeamsBot();
|
|
18
|
+
|
|
19
|
+
// Health check endpoint (the deploy pipeline gates on this)
|
|
20
|
+
app.get('/health', (_req: Request, res: Response) => {
|
|
21
|
+
res.json({
|
|
22
|
+
status: 'healthy',
|
|
23
|
+
timestamp: new Date().toISOString(),
|
|
24
|
+
mockMode: config.mockMode,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Bot messaging endpoint
|
|
29
|
+
app.post('/api/messages', async (req: Request, res: Response) => {
|
|
30
|
+
try {
|
|
31
|
+
await adapter.process(req, res, (context) => bot.run(context));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error('Error processing message:', error);
|
|
34
|
+
res.status(500).send('Internal Server Error');
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
app.get('/', (_req: Request, res: Response) => {
|
|
39
|
+
res.json({
|
|
40
|
+
name: '{{projectName}}',
|
|
41
|
+
description: 'Microsoft Teams echo bot (Bot Framework SDK v4)',
|
|
42
|
+
endpoints: { messaging: '/api/messages', health: '/health' },
|
|
43
|
+
mockMode: config.mockMode,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
app.listen(config.port, () => {
|
|
48
|
+
console.log(`\nBot server is running!`);
|
|
49
|
+
console.log(` Local: http://localhost:${config.port}`);
|
|
50
|
+
console.log(` Health: http://localhost:${config.port}/health`);
|
|
51
|
+
console.log(` Mode: ${config.mockMode ? 'Mock (development)' : 'Production'}`);
|
|
52
|
+
console.log(`\n Messaging endpoint: /api/messages`);
|
|
53
|
+
|
|
54
|
+
if (config.mockMode) {
|
|
55
|
+
console.log(`\nTip: run with --interactive for console testing:`);
|
|
56
|
+
console.log(` npm run dev -- --interactive`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { TestAdapter, Activity, ConversationReference, TurnContext } from 'botbuilder';
|
|
2
|
+
import { TeamsBot } from '../bot/TeamsBot';
|
|
3
|
+
|
|
4
|
+
// Create a test adapter for mock mode testing
|
|
5
|
+
export function createMockAdapter(): TestAdapter {
|
|
6
|
+
const adapter = new TestAdapter(async (context: TurnContext) => {
|
|
7
|
+
const bot = new TeamsBot();
|
|
8
|
+
await bot.run(context);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
return adapter;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Simulate a Teams conversation for testing
|
|
15
|
+
export async function runMockConversation(
|
|
16
|
+
userMessage: string,
|
|
17
|
+
callback?: (response: Partial<Activity>[]) => void
|
|
18
|
+
): Promise<Partial<Activity>[]> {
|
|
19
|
+
const adapter = createMockAdapter();
|
|
20
|
+
const responses: Partial<Activity>[] = [];
|
|
21
|
+
|
|
22
|
+
await adapter.send(userMessage).assertReply((activity) => {
|
|
23
|
+
responses.push(activity);
|
|
24
|
+
callback?.([activity]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return responses;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Create a mock conversation reference for proactive messaging tests
|
|
31
|
+
export function createMockConversationReference(
|
|
32
|
+
userId: string = 'user-1'
|
|
33
|
+
): Partial<ConversationReference> {
|
|
34
|
+
return {
|
|
35
|
+
activityId: 'mock-activity-id',
|
|
36
|
+
user: {
|
|
37
|
+
id: userId,
|
|
38
|
+
name: 'Mock User',
|
|
39
|
+
},
|
|
40
|
+
bot: {
|
|
41
|
+
id: 'mock-bot-id',
|
|
42
|
+
name: 'Mock Bot',
|
|
43
|
+
},
|
|
44
|
+
conversation: {
|
|
45
|
+
id: 'mock-conversation-id',
|
|
46
|
+
isGroup: false,
|
|
47
|
+
conversationType: 'personal',
|
|
48
|
+
tenantId: 'mock-tenant-id',
|
|
49
|
+
name: '',
|
|
50
|
+
},
|
|
51
|
+
channelId: 'msteams',
|
|
52
|
+
serviceUrl: 'https://smba.trafficmanager.net/teams/',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Interactive console testing
|
|
57
|
+
export async function startInteractiveTest(): Promise<void> {
|
|
58
|
+
const readline = await import('readline');
|
|
59
|
+
const rl = readline.createInterface({
|
|
60
|
+
input: process.stdin,
|
|
61
|
+
output: process.stdout,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
console.log('\n=== Bot Mock Mode Interactive Test ===');
|
|
65
|
+
console.log('Type messages to test the bot. Type "exit" to quit.\n');
|
|
66
|
+
|
|
67
|
+
const adapter = createMockAdapter();
|
|
68
|
+
|
|
69
|
+
const askQuestion = (): void => {
|
|
70
|
+
rl.question('You: ', async (input) => {
|
|
71
|
+
if (input.toLowerCase() === 'exit') {
|
|
72
|
+
console.log('Goodbye!');
|
|
73
|
+
rl.close();
|
|
74
|
+
process.exit(0);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await adapter.send(input).assertReply((activity) => {
|
|
80
|
+
if (activity.text) {
|
|
81
|
+
console.log(`Bot: ${activity.text}`);
|
|
82
|
+
}
|
|
83
|
+
if (activity.attachments && activity.attachments.length > 0) {
|
|
84
|
+
console.log('Bot: [Adaptive Card]');
|
|
85
|
+
const card = activity.attachments[0].content;
|
|
86
|
+
if (card.body) {
|
|
87
|
+
for (const block of card.body) {
|
|
88
|
+
if (block.type === 'TextBlock' && block.text) {
|
|
89
|
+
console.log(` ${block.text}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
console.log('Bot: [No response]');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
askQuestion();
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
askQuestion();
|
|
104
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.19/MicrosoftTeams.schema.json",
|
|
3
|
+
"manifestVersion": "1.19",
|
|
4
|
+
"id": "{{TEAMS_APP_ID}}",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"developer": {
|
|
7
|
+
"name": "Contoso",
|
|
8
|
+
"websiteUrl": "https://example.com",
|
|
9
|
+
"privacyUrl": "https://example.com/privacy",
|
|
10
|
+
"termsOfUseUrl": "https://example.com/terms"
|
|
11
|
+
},
|
|
12
|
+
"name": { "short": "{{projectName}}", "full": "{{projectName}}" },
|
|
13
|
+
"description": { "short": "{{projectName}} notifications", "full": "{{projectName}} — a Microsoft Teams bot that delivers proactive notifications; message it 'subscribe' to opt in" },
|
|
14
|
+
"icons": { "color": "color.png", "outline": "outline.png" },
|
|
15
|
+
"accentColor": "#6264A7",
|
|
16
|
+
"bots": [
|
|
17
|
+
{
|
|
18
|
+
"botId": "{{BOT_ID}}",
|
|
19
|
+
"scopes": ["personal", "team", "groupChat"],
|
|
20
|
+
"supportsFiles": false,
|
|
21
|
+
"isNotificationOnly": false
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"permissions": ["identity"]
|
|
25
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { TeamsActivityHandler, TurnContext, MessageFactory, CardFactory, Attachment } from 'botbuilder';
|
|
2
|
+
import { IConversationStore } from '../storage/conversationStore';
|
|
3
|
+
|
|
4
|
+
export class NotificationBot extends TeamsActivityHandler {
|
|
5
|
+
constructor(private conversationStore: IConversationStore) {
|
|
6
|
+
super();
|
|
7
|
+
|
|
8
|
+
// Capture the conversation reference whenever we hear from a user — this is
|
|
9
|
+
// what proactive messaging replays later via continueConversationAsync.
|
|
10
|
+
this.onConversationUpdate(async (context: TurnContext, next: () => Promise<void>) => {
|
|
11
|
+
await this.saveConversationReference(context);
|
|
12
|
+
await next();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
this.onMessage(async (context: TurnContext, next: () => Promise<void>) => {
|
|
16
|
+
await this.saveConversationReference(context);
|
|
17
|
+
const text = context.activity.text?.toLowerCase().trim() || '';
|
|
18
|
+
|
|
19
|
+
if (text === 'subscribe') {
|
|
20
|
+
await context.sendActivity(MessageFactory.text('You are subscribed to notifications!'));
|
|
21
|
+
} else if (text === 'unsubscribe') {
|
|
22
|
+
const userId = context.activity.from.id;
|
|
23
|
+
await this.conversationStore.delete(userId);
|
|
24
|
+
await context.sendActivity(MessageFactory.text('You have been unsubscribed.'));
|
|
25
|
+
} else {
|
|
26
|
+
await context.sendActivity(
|
|
27
|
+
MessageFactory.text('Commands: **subscribe**, **unsubscribe**. Notifications arrive via POST /api/notify.')
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
await next();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async saveConversationReference(context: TurnContext): Promise<void> {
|
|
35
|
+
const reference = TurnContext.getConversationReference(context.activity);
|
|
36
|
+
const userId = context.activity.from?.id;
|
|
37
|
+
if (userId) {
|
|
38
|
+
await this.conversationStore.save(userId, reference);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static createNotificationCard(title: string, message: string): Attachment {
|
|
43
|
+
return CardFactory.adaptiveCard({
|
|
44
|
+
type: 'AdaptiveCard',
|
|
45
|
+
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
|
46
|
+
version: '1.4',
|
|
47
|
+
body: [
|
|
48
|
+
{ type: 'TextBlock', text: title, weight: 'Bolder', size: 'Medium', wrap: true },
|
|
49
|
+
{ type: 'TextBlock', text: message, wrap: true },
|
|
50
|
+
{ type: 'TextBlock', text: new Date().toLocaleString(), size: 'Small', isSubtle: true },
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import express, { Request, Response } from 'express';
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { config, validateConfig } from './config';
|
|
4
|
+
import { createBotAdapter } from './adapters/botAdapter';
|
|
5
|
+
import { NotificationBot } from './bot/NotificationBot';
|
|
6
|
+
import { FileConversationStore, MemoryConversationStore } from './storage/conversationStore';
|
|
7
|
+
import { startInteractiveTest } from './mock/mockMode';
|
|
8
|
+
|
|
9
|
+
// Length-checked constant-time compare (timingSafeEqual throws on length mismatch) so the
|
|
10
|
+
// shared X-Notify-Key can't be recovered a byte at a time via response-timing analysis.
|
|
11
|
+
function timingSafeEqualStr(a: string, b: string): boolean {
|
|
12
|
+
const ab = Buffer.from(a);
|
|
13
|
+
const bb = Buffer.from(b);
|
|
14
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
validateConfig();
|
|
18
|
+
|
|
19
|
+
// Interactive mock mode: console chat loop, no server / no Azure.
|
|
20
|
+
if (process.argv.includes('--interactive')) {
|
|
21
|
+
startInteractiveTest();
|
|
22
|
+
} else {
|
|
23
|
+
const app = express();
|
|
24
|
+
app.use(express.json());
|
|
25
|
+
|
|
26
|
+
const adapter = createBotAdapter();
|
|
27
|
+
// File store by default so subscriptions survive restarts; memory store in mock mode.
|
|
28
|
+
const conversationStore = config.mockMode
|
|
29
|
+
? new MemoryConversationStore()
|
|
30
|
+
: new FileConversationStore(config.conversationStorePath);
|
|
31
|
+
const bot = new NotificationBot(conversationStore);
|
|
32
|
+
|
|
33
|
+
// Health check endpoint (the deploy pipeline gates on this)
|
|
34
|
+
app.get('/health', (_req: Request, res: Response) => {
|
|
35
|
+
res.json({
|
|
36
|
+
status: 'healthy',
|
|
37
|
+
timestamp: new Date().toISOString(),
|
|
38
|
+
mockMode: config.mockMode,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Bot messaging endpoint
|
|
43
|
+
app.post('/api/messages', async (req: Request, res: Response) => {
|
|
44
|
+
try {
|
|
45
|
+
await adapter.process(req, res, (context) => bot.run(context));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('Error processing message:', error);
|
|
48
|
+
res.status(500).send('Internal Server Error');
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Proactive notification endpoint. Fail-closed: without a configured key the
|
|
53
|
+
// endpoint stays off (this is a public URL — an open POST would be a spam relay).
|
|
54
|
+
app.post('/api/notify', async (req: Request, res: Response) => {
|
|
55
|
+
if (!config.notifyApiKey) {
|
|
56
|
+
res.status(503).json({ error: 'NOTIFY_API_KEY is not configured; endpoint disabled.' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const providedKey = req.get('X-Notify-Key');
|
|
60
|
+
if (!providedKey || !timingSafeEqualStr(providedKey, config.notifyApiKey)) {
|
|
61
|
+
res.status(401).json({ error: 'Missing or invalid X-Notify-Key header.' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { title, message, userId } = req.body as {
|
|
66
|
+
title?: string;
|
|
67
|
+
message?: string;
|
|
68
|
+
userId?: string;
|
|
69
|
+
};
|
|
70
|
+
if (!title || !message) {
|
|
71
|
+
res.status(400).json({ error: 'Both "title" and "message" are required.' });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sendTo = async (reference: Parameters<typeof adapter.continueConversationAsync>[1]) => {
|
|
76
|
+
await adapter.continueConversationAsync(config.botId, reference, async (context) => {
|
|
77
|
+
await context.sendActivity({
|
|
78
|
+
attachments: [NotificationBot.createNotificationCard(title, message)],
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
if (userId) {
|
|
85
|
+
const reference = await conversationStore.get(userId);
|
|
86
|
+
if (!reference) {
|
|
87
|
+
res.status(404).json({ error: `No conversation reference for user "${userId}".` });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await sendTo(reference);
|
|
91
|
+
res.json({ success: true, sent: 1 });
|
|
92
|
+
} else {
|
|
93
|
+
// Broadcast to every stored reference; tolerate stale ones.
|
|
94
|
+
const refs = await conversationStore.getAll();
|
|
95
|
+
let sent = 0;
|
|
96
|
+
let failed = 0;
|
|
97
|
+
for (const [id, reference] of refs) {
|
|
98
|
+
try {
|
|
99
|
+
await sendTo(reference);
|
|
100
|
+
sent++;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
failed++;
|
|
103
|
+
console.warn(`Failed to notify ${id}:`, err);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
res.json({ success: true, sent, failed });
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error('Error sending notification:', error);
|
|
110
|
+
res.status(500).json({ error: 'Failed to send notification.' });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
app.get('/', (_req: Request, res: Response) => {
|
|
115
|
+
res.json({
|
|
116
|
+
name: '{{projectName}}',
|
|
117
|
+
description: 'Microsoft Teams notification bot (Bot Framework SDK v4, proactive messaging)',
|
|
118
|
+
endpoints: { messaging: '/api/messages', notify: '/api/notify', health: '/health' },
|
|
119
|
+
mockMode: config.mockMode,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
app.listen(config.port, () => {
|
|
124
|
+
console.log(`\nNotification bot server is running!`);
|
|
125
|
+
console.log(` Local: http://localhost:${config.port}`);
|
|
126
|
+
console.log(` Health: http://localhost:${config.port}/health`);
|
|
127
|
+
console.log(` Mode: ${config.mockMode ? 'Mock (development)' : 'Production'}`);
|
|
128
|
+
console.log(`\n Messaging endpoint: /api/messages`);
|
|
129
|
+
console.log(` Notify endpoint: /api/notify (X-Notify-Key required)`);
|
|
130
|
+
|
|
131
|
+
if (config.mockMode) {
|
|
132
|
+
console.log(`\nTip: run with --interactive for console testing:`);
|
|
133
|
+
console.log(` npm run dev -- --interactive`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { TestAdapter, TurnContext } from 'botbuilder';
|
|
2
|
+
import { NotificationBot } from '../bot/NotificationBot';
|
|
3
|
+
import { MemoryConversationStore } from '../storage/conversationStore';
|
|
4
|
+
|
|
5
|
+
// Create a test adapter for mock mode testing (memory store — nothing persists)
|
|
6
|
+
export function createMockAdapter(): TestAdapter {
|
|
7
|
+
const bot = new NotificationBot(new MemoryConversationStore());
|
|
8
|
+
const adapter = new TestAdapter(async (context: TurnContext) => {
|
|
9
|
+
await bot.run(context);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
return adapter;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Interactive console testing
|
|
16
|
+
export async function startInteractiveTest(): Promise<void> {
|
|
17
|
+
const readline = await import('readline');
|
|
18
|
+
const rl = readline.createInterface({
|
|
19
|
+
input: process.stdin,
|
|
20
|
+
output: process.stdout,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
console.log('\n=== Notification Bot Mock Mode ===');
|
|
24
|
+
console.log('Try "subscribe" / "unsubscribe". Type "exit" to quit.\n');
|
|
25
|
+
|
|
26
|
+
const adapter = createMockAdapter();
|
|
27
|
+
|
|
28
|
+
const askQuestion = (): void => {
|
|
29
|
+
rl.question('You: ', async (input) => {
|
|
30
|
+
if (input.toLowerCase() === 'exit') {
|
|
31
|
+
console.log('Goodbye!');
|
|
32
|
+
rl.close();
|
|
33
|
+
process.exit(0);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await adapter.send(input).assertReply((activity) => {
|
|
39
|
+
if (activity.text) {
|
|
40
|
+
console.log(`Bot: ${activity.text}`);
|
|
41
|
+
}
|
|
42
|
+
if (activity.attachments && activity.attachments.length > 0) {
|
|
43
|
+
console.log('Bot: [Adaptive Card]');
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
console.log('Bot: [No response]');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
askQuestion();
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
askQuestion();
|
|
55
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ConversationReference } from 'botbuilder';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
export interface IConversationStore {
|
|
6
|
+
save(userId: string, reference: Partial<ConversationReference>): Promise<void>;
|
|
7
|
+
get(userId: string): Promise<Partial<ConversationReference> | null>;
|
|
8
|
+
getAll(): Promise<Map<string, Partial<ConversationReference>>>;
|
|
9
|
+
delete(userId: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class MemoryConversationStore implements IConversationStore {
|
|
13
|
+
private store = new Map<string, Partial<ConversationReference>>();
|
|
14
|
+
|
|
15
|
+
async save(userId: string, reference: Partial<ConversationReference>): Promise<void> {
|
|
16
|
+
this.store.set(userId, reference);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async get(userId: string): Promise<Partial<ConversationReference> | null> {
|
|
20
|
+
return this.store.get(userId) || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async getAll(): Promise<Map<string, Partial<ConversationReference>>> {
|
|
24
|
+
return new Map(this.store);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async delete(userId: string): Promise<void> {
|
|
28
|
+
this.store.delete(userId);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Write-through JSON-file store so subscriptions survive process restarts.
|
|
34
|
+
* On Azure App Service point it at /home/data/conversations.json — /home is
|
|
35
|
+
* persistent storage that also survives redeploys (unlike wwwroot).
|
|
36
|
+
*/
|
|
37
|
+
export class FileConversationStore implements IConversationStore {
|
|
38
|
+
private store = new Map<string, Partial<ConversationReference>>();
|
|
39
|
+
private loaded = false;
|
|
40
|
+
|
|
41
|
+
constructor(private filePath: string) {}
|
|
42
|
+
|
|
43
|
+
private async load(): Promise<void> {
|
|
44
|
+
if (this.loaded) return;
|
|
45
|
+
this.loaded = true;
|
|
46
|
+
try {
|
|
47
|
+
const raw = await fs.readFile(this.filePath, 'utf8');
|
|
48
|
+
const entries = JSON.parse(raw) as [string, Partial<ConversationReference>][];
|
|
49
|
+
this.store = new Map(entries);
|
|
50
|
+
console.log(`Loaded ${this.store.size} conversation reference(s) from ${this.filePath}`);
|
|
51
|
+
} catch (err: unknown) {
|
|
52
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
53
|
+
console.warn(`Could not read conversation store at ${this.filePath}:`, err);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async persist(): Promise<void> {
|
|
59
|
+
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
60
|
+
await fs.writeFile(this.filePath, JSON.stringify([...this.store.entries()], null, 2), 'utf8');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async save(userId: string, reference: Partial<ConversationReference>): Promise<void> {
|
|
64
|
+
await this.load();
|
|
65
|
+
this.store.set(userId, reference);
|
|
66
|
+
await this.persist();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async get(userId: string): Promise<Partial<ConversationReference> | null> {
|
|
70
|
+
await this.load();
|
|
71
|
+
return this.store.get(userId) || null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async getAll(): Promise<Map<string, Partial<ConversationReference>>> {
|
|
75
|
+
await this.load();
|
|
76
|
+
return new Map(this.store);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async delete(userId: string): Promise<void> {
|
|
80
|
+
await this.load();
|
|
81
|
+
this.store.delete(userId);
|
|
82
|
+
await this.persist();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
Binary file
|
|
Binary file
|