Skip to content

Commit 35264ba

Browse files
authored
Merge pull request #329 from keboola/upstream/slack-provider
Add Slack platform integration with database-backed configuration
2 parents df88f8d + 1c95d26 commit 35264ba

File tree

11 files changed

+390
-33
lines changed

11 files changed

+390
-33
lines changed

src/OpenDeepWiki/Chat/ChatServiceExtensions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,10 @@ private static IServiceCollection AddChatBackgroundServices(this IServiceCollect
183183
{
184184
services.AddHostedService<ConfigReloadService>();
185185
}
186-
186+
187+
// Apply DB config to providers after they are initialized
188+
services.AddHostedService<ProviderConfigApplicator>();
189+
187190
return services;
188191
}
189192

src/OpenDeepWiki/Chat/Config/ChatConfigService.cs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,29 @@ public class ChatConfigService : IChatConfigService
1313
{
1414
private readonly IContext _context;
1515
private readonly IConfigEncryption _encryption;
16+
private readonly IConfigChangeNotifier _changeNotifier;
1617
private readonly ILogger<ChatConfigService> _logger;
1718
private readonly List<Action<string>> _changeCallbacks = new();
1819
private readonly object _callbackLock = new();
19-
20+
2021
// 必需的配置字段(按平台)
2122
private static readonly Dictionary<string, string[]> RequiredFields = new()
2223
{
2324
["feishu"] = new[] { "AppId", "AppSecret" },
2425
["qq"] = new[] { "AppId", "Token" },
25-
["wechat"] = new[] { "AppId", "AppSecret", "Token", "EncodingAesKey" }
26+
["wechat"] = new[] { "AppId", "AppSecret", "Token", "EncodingAesKey" },
27+
["slack"] = new[] { "BotToken", "SigningSecret" }
2628
};
2729

2830
public ChatConfigService(
2931
IContext context,
3032
IConfigEncryption encryption,
33+
IConfigChangeNotifier changeNotifier,
3134
ILogger<ChatConfigService> logger)
3235
{
3336
_context = context;
3437
_encryption = encryption;
38+
_changeNotifier = changeNotifier;
3539
_logger = logger;
3640
}
3741

@@ -113,9 +117,9 @@ public async Task DeleteConfigAsync(string platform, CancellationToken cancellat
113117
_context.ChatProviderConfigs.Remove(entity);
114118
await _context.SaveChangesAsync(cancellationToken);
115119
_logger.LogInformation("Deleted config for platform: {Platform}", platform);
116-
120+
117121
// 触发变更通知
118-
NotifyConfigChanged(platform);
122+
NotifyConfigChanged(platform, ConfigChangeType.Deleted);
119123
}
120124
}
121125

@@ -263,14 +267,15 @@ private static string[] GetRequiredFieldsForPlatform(string platform)
263267
/// <summary>
264268
/// 通知配置变更
265269
/// </summary>
266-
private void NotifyConfigChanged(string platform)
270+
private void NotifyConfigChanged(string platform, ConfigChangeType changeType = ConfigChangeType.Updated)
267271
{
272+
// Fire local scoped callbacks (existing behavior)
268273
List<Action<string>> callbacks;
269274
lock (_callbackLock)
270275
{
271276
callbacks = _changeCallbacks.ToList();
272277
}
273-
278+
274279
foreach (var callback in callbacks)
275280
{
276281
try
@@ -282,6 +287,16 @@ private void NotifyConfigChanged(string platform)
282287
_logger.LogError(ex, "Error in config change callback for platform: {Platform}", platform);
283288
}
284289
}
290+
291+
// Fire global singleton notifier so ProviderConfigApplicator picks up changes immediately
292+
try
293+
{
294+
_changeNotifier.NotifyChange(platform, changeType);
295+
}
296+
catch (Exception ex)
297+
{
298+
_logger.LogError(ex, "Error firing global config change notification for platform: {Platform}", platform);
299+
}
285300
}
286301

287302
/// <summary>
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.Hosting;
3+
using Microsoft.Extensions.Logging;
4+
using OpenDeepWiki.Chat.Providers;
5+
using OpenDeepWiki.Chat.Routing;
6+
7+
namespace OpenDeepWiki.Chat.Config;
8+
9+
/// <summary>
10+
/// Bridges database configuration to live provider instances.
11+
/// On startup, loads DB config for all registered IConfigurableProvider providers.
12+
/// At runtime, subscribes to IConfigChangeNotifier to apply config changes immediately.
13+
/// </summary>
14+
public class ProviderConfigApplicator : IHostedService, IDisposable
15+
{
16+
private readonly IMessageRouter _router;
17+
private readonly IConfigChangeNotifier _changeNotifier;
18+
private readonly IServiceScopeFactory _scopeFactory;
19+
private readonly ILogger<ProviderConfigApplicator> _logger;
20+
private IDisposable? _subscription;
21+
22+
public ProviderConfigApplicator(
23+
IMessageRouter router,
24+
IConfigChangeNotifier changeNotifier,
25+
IServiceScopeFactory scopeFactory,
26+
ILogger<ProviderConfigApplicator> logger)
27+
{
28+
_router = router;
29+
_changeNotifier = changeNotifier;
30+
_scopeFactory = scopeFactory;
31+
_logger = logger;
32+
}
33+
34+
public async Task StartAsync(CancellationToken cancellationToken)
35+
{
36+
_logger.LogInformation("ProviderConfigApplicator starting: applying DB configs to registered providers");
37+
38+
await ApplyAllDbConfigsAsync(cancellationToken);
39+
40+
// Subscribe to all platform changes for runtime hot-reload
41+
_subscription = _changeNotifier.Subscribe(null, OnConfigChanged);
42+
43+
_logger.LogInformation("ProviderConfigApplicator started: subscribed to config change notifications");
44+
}
45+
46+
public Task StopAsync(CancellationToken cancellationToken)
47+
{
48+
_subscription?.Dispose();
49+
_subscription = null;
50+
_logger.LogInformation("ProviderConfigApplicator stopped");
51+
return Task.CompletedTask;
52+
}
53+
54+
public void Dispose()
55+
{
56+
_subscription?.Dispose();
57+
}
58+
59+
private async Task ApplyAllDbConfigsAsync(CancellationToken cancellationToken)
60+
{
61+
using var scope = _scopeFactory.CreateScope();
62+
var configService = scope.ServiceProvider.GetRequiredService<IChatConfigService>();
63+
64+
foreach (var provider in _router.GetAllProviders())
65+
{
66+
if (provider is not IConfigurableProvider configurable)
67+
continue;
68+
69+
try
70+
{
71+
var dbConfig = await configService.GetConfigAsync(provider.PlatformId, cancellationToken);
72+
if (dbConfig != null)
73+
{
74+
configurable.ApplyConfig(dbConfig);
75+
_logger.LogInformation(
76+
"Applied DB config to provider {Platform} at startup", provider.PlatformId);
77+
}
78+
else
79+
{
80+
_logger.LogDebug(
81+
"No DB config for provider {Platform}, using environment variable defaults",
82+
provider.PlatformId);
83+
}
84+
}
85+
catch (Exception ex)
86+
{
87+
_logger.LogError(ex,
88+
"Failed to apply DB config to provider {Platform} at startup, falling back to env vars",
89+
provider.PlatformId);
90+
}
91+
}
92+
}
93+
94+
private void OnConfigChanged(ConfigChangeEvent evt)
95+
{
96+
// Fire-and-forget with error handling (notification callback is synchronous)
97+
_ = Task.Run(async () =>
98+
{
99+
try
100+
{
101+
await ApplyConfigForPlatformAsync(evt.Platform, evt.ChangeType);
102+
}
103+
catch (Exception ex)
104+
{
105+
_logger.LogError(ex, "Failed to apply config change for platform {Platform}", evt.Platform);
106+
}
107+
});
108+
}
109+
110+
private async Task ApplyConfigForPlatformAsync(string platform, ConfigChangeType changeType)
111+
{
112+
var provider = _router.GetProvider(platform);
113+
if (provider is not IConfigurableProvider configurable)
114+
{
115+
_logger.LogDebug("Provider {Platform} does not support runtime config updates", platform);
116+
return;
117+
}
118+
119+
if (changeType == ConfigChangeType.Deleted)
120+
{
121+
configurable.ResetToDefaults();
122+
_logger.LogInformation("Reset provider {Platform} to env var defaults (DB config deleted)", platform);
123+
return;
124+
}
125+
126+
using var scope = _scopeFactory.CreateScope();
127+
var configService = scope.ServiceProvider.GetRequiredService<IChatConfigService>();
128+
var dbConfig = await configService.GetConfigAsync(platform);
129+
130+
if (dbConfig != null)
131+
{
132+
configurable.ApplyConfig(dbConfig);
133+
_logger.LogInformation("Applied updated DB config to provider {Platform} (change: {ChangeType})",
134+
platform, changeType);
135+
}
136+
else
137+
{
138+
configurable.ResetToDefaults();
139+
_logger.LogInformation("Reset provider {Platform} to env var defaults (DB config not found)", platform);
140+
}
141+
}
142+
}

src/OpenDeepWiki/Chat/Execution/ChatUserResolver.cs

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
using System.Text.Json;
44
using Microsoft.EntityFrameworkCore;
55
using Microsoft.Extensions.Logging;
6-
using Microsoft.Extensions.Options;
76
using OpenDeepWiki.Chat.Providers.Slack;
7+
using OpenDeepWiki.Chat.Routing;
88
using OpenDeepWiki.EFCore;
99

1010
namespace OpenDeepWiki.Chat.Execution;
@@ -24,11 +24,13 @@ public interface IChatUserResolver
2424
/// <summary>
2525
/// Resolves Slack (and other platform) user IDs to DeepWiki user IDs by email matching.
2626
/// Singleton service with in-memory caching.
27+
/// Reads Slack config from the live SlackProvider (via IMessageRouter) so that
28+
/// database-backed config changes are picked up automatically.
2729
/// </summary>
2830
public class ChatUserResolver : IChatUserResolver
2931
{
3032
private readonly IContextFactory _contextFactory;
31-
private readonly SlackProviderOptions _slackOptions;
33+
private readonly IMessageRouter _messageRouter;
3234
private readonly HttpClient _httpClient;
3335
private readonly ILogger<ChatUserResolver> _logger;
3436

@@ -38,12 +40,12 @@ public class ChatUserResolver : IChatUserResolver
3840

3941
public ChatUserResolver(
4042
IContextFactory contextFactory,
41-
IOptions<SlackProviderOptions> slackOptions,
43+
IMessageRouter messageRouter,
4244
HttpClient httpClient,
4345
ILogger<ChatUserResolver> logger)
4446
{
4547
_contextFactory = contextFactory;
46-
_slackOptions = slackOptions.Value;
48+
_messageRouter = messageRouter;
4749
_httpClient = httpClient;
4850
_logger = logger;
4951
}
@@ -114,18 +116,30 @@ public ChatUserResolver(
114116
/// <summary>
115117
/// Calls Slack users.info API to get the user's email address.
116118
/// Requires users:read.email OAuth scope on the Slack App.
119+
/// Reads BotToken from the live SlackProvider registered in the router,
120+
/// which reflects database-backed config updates.
117121
/// </summary>
118122
private async Task<string?> GetSlackUserEmailAsync(string slackUserId, CancellationToken cancellationToken)
119123
{
120-
if (string.IsNullOrEmpty(_slackOptions.BotToken))
124+
var slackProvider = _messageRouter.GetProvider("slack") as SlackProvider;
125+
if (slackProvider == null)
126+
{
127+
_logger.LogWarning("Slack provider not registered, cannot resolve user email");
128+
return null;
129+
}
130+
131+
var botToken = slackProvider.ActiveBotToken;
132+
var apiBaseUrl = slackProvider.ActiveApiBaseUrl;
133+
134+
if (string.IsNullOrEmpty(botToken))
121135
{
122136
_logger.LogWarning("Slack BotToken not configured, cannot resolve user email");
123137
return null;
124138
}
125139

126-
var url = $"{_slackOptions.ApiBaseUrl}/users.info?user={slackUserId}";
140+
var url = $"{apiBaseUrl}/users.info?user={slackUserId}";
127141
var request = new HttpRequestMessage(HttpMethod.Get, url);
128-
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _slackOptions.BotToken);
142+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", botToken);
129143

130144
var response = await _httpClient.SendAsync(request, cancellationToken);
131145
var content = await response.Content.ReadAsStringAsync(cancellationToken);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using OpenDeepWiki.Chat.Config;
2+
3+
namespace OpenDeepWiki.Chat.Providers;
4+
5+
/// <summary>
6+
/// Interface for providers that support runtime configuration updates from database.
7+
/// Providers implementing this interface can have their config applied/reverted
8+
/// by the ProviderConfigApplicator hosted service.
9+
/// </summary>
10+
public interface IConfigurableProvider
11+
{
12+
/// <summary>
13+
/// Apply configuration from database DTO.
14+
/// Thread-safe: implementations must ensure concurrent requests see a consistent state.
15+
/// </summary>
16+
void ApplyConfig(ProviderConfigDto config);
17+
18+
/// <summary>
19+
/// Reset to environment-variable-based configuration (when DB config is deleted).
20+
/// </summary>
21+
void ResetToDefaults();
22+
23+
/// <summary>
24+
/// Where the current config came from: "database" or "environment".
25+
/// </summary>
26+
string ConfigSource { get; }
27+
}

0 commit comments

Comments
 (0)