-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathmeeting_transcriber.py
More file actions
281 lines (229 loc) · 9.3 KB
/
Copy pathmeeting_transcriber.py
File metadata and controls
281 lines (229 loc) · 9.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
"""
Meeting transcription example for discli, with Claude-powered summary on exit.
Joins a server voice channel, transcribes every speaker live with their
display names, appends to ~/.discli/transcripts/meeting-<timestamp>.md, and
asks Claude (via the Agent SDK) to summarize the meeting when you press Ctrl+C.
Bot tokens only — Discord's API restricts application bots to server voice
channels, so DM and group-DM voice calls aren't supported here. Test in a
server voice channel.
Requires the DAVE-aware patches in ``discli.voice_engine`` (applied
automatically by ``VoiceEngine.listen_start``).
Setup:
discli config set token YOUR_BOT_TOKEN
export DEEPGRAM_API_KEY=... # or use --stt openai
Usage:
python examples/meeting_transcriber.py <voice_channel_id>
python examples/meeting_transcriber.py 1016638171854938152 --stt openai
The summary on exit uses your existing Claude Code authentication — no
Anthropic API key required.
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from datetime import datetime
from pathlib import Path
# Allow running inside a Claude Code session.
os.environ.pop("CLAUDECODE", None)
# Force UTF-8 on Windows so non-ASCII transcript text doesn't crash the print.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
import discord
import claude_agent_sdk as sdk
from discli.config import load_config
from discli.voice_engine import VoiceEngine
SUMMARY_SYSTEM_PROMPT = """You are summarizing a Discord voice-channel meeting transcript.
Output GitHub-flavored markdown with exactly these sections, in this order:
## Summary
Two or three sentences capturing the meeting's purpose and outcome.
## Key decisions
Bulleted list of decisions made. Omit the section entirely if none.
## Action items
Bulleted list. Include the owner (display name as written in the transcript) if
mentioned, and a target date or deadline if mentioned.
## Open questions
Bulleted list of unresolved questions. Omit the section entirely if none.
Be concise. Refer to participants by the display names exactly as written in
the transcript."""
def _resolve_token() -> str | None:
"""Find a Discord bot token: env var first, then ~/.discli/config.json."""
return os.environ.get("DISCORD_TOKEN") or load_config().get("token")
async def run(channel_id: int, stt_provider: str) -> None:
token = _resolve_token()
if not token:
sys.exit(
"No Discord bot token found. Run 'discli config set token <TOKEN>' "
"or set the DISCORD_TOKEN environment variable."
)
# `members` intent so display-name lookups work for everyone in the call,
# not just the bot's recent cache.
intents = discord.Intents.default()
intents.members = True
client = discord.Client(intents=intents)
ready = asyncio.Event()
@client.event
async def on_ready():
print(f"Connected as {client.user} ({client.user.id})", flush=True)
ready.set()
client_task = asyncio.create_task(client.start(token))
await ready.wait()
channel = client.get_channel(channel_id)
if channel is None:
try:
channel = await client.fetch_channel(channel_id)
except discord.NotFound:
sys.exit(f"Channel {channel_id} not found.")
except discord.Forbidden:
sys.exit(f"Bot does not have access to channel {channel_id}.")
except discord.HTTPException as exc:
sys.exit(f"Discord API error fetching channel {channel_id}: {exc!r}")
if not isinstance(channel, discord.VoiceChannel):
sys.exit(
f"Channel {channel_id} is a {type(channel).__name__}, not a server "
f"voice channel. Bot tokens cannot join DM or group voice calls."
)
started_at = datetime.now()
stamp = started_at.strftime("%Y%m%d-%H%M%S")
transcript_dir = Path.home() / ".discli" / "transcripts"
transcript_dir.mkdir(parents=True, exist_ok=True)
path = transcript_dir / f"meeting-{stamp}.md"
with path.open("w", encoding="utf-8") as f:
f.write(f"# Meeting transcript — {started_at.strftime('%Y-%m-%d %H:%M:%S')}\n\n")
f.write(f"- **Channel:** #{channel.name} ({channel.guild.name})\n")
f.write(f"- **Bot:** {client.user}\n\n")
f.write("## Transcript\n\n")
engine = VoiceEngine(config={"stt_provider": stt_provider})
transcript_lines: list[str] = []
name_cache: dict[int, str] = {}
def resolve_name(uid: int) -> str:
cached = name_cache.get(uid)
if cached:
return cached
member = channel.guild.get_member(uid)
if member is not None:
name = member.display_name
else:
user = client.get_user(uid)
name = user.display_name if user else f"user-{uid}"
name_cache[uid] = name
return name
def on_event(event: dict) -> None:
if event.get("event") != "voice_speech_detected":
return
if not event.get("is_final"):
return
text = (event.get("text") or "").strip()
if not text:
return
try:
uid = int(event.get("user_id") or 0)
except (TypeError, ValueError):
return
if uid == client.user.id:
return
name = resolve_name(uid)
ts = datetime.now().strftime("%H:%M:%S")
line = f"- **[{ts}] {name}:** {text}"
transcript_lines.append(line)
print(line, flush=True)
try:
with path.open("a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception as exc:
print(f"[transcript] write error: {exc!r}", file=sys.stderr, flush=True)
engine.set_event_handler(on_event)
await engine.connect(channel)
await engine.listen_start(channel.guild.id)
print(f"\nListening to #{channel.name}. Transcript: {path}")
print("Press Ctrl+C to stop and generate a summary.\n", flush=True)
try:
while True:
await asyncio.sleep(1.0)
except (asyncio.CancelledError, KeyboardInterrupt):
pass
finally:
print("\nStopping listener…", flush=True)
try:
engine.listen_stop(channel.guild.id)
except Exception as exc:
print(f"listen_stop error: {exc!r}", file=sys.stderr, flush=True)
try:
await engine.disconnect(channel.guild.id)
except Exception as exc:
print(f"disconnect error: {exc!r}", file=sys.stderr, flush=True)
if transcript_lines:
print(f"Generating summary from {len(transcript_lines)} line(s)…", flush=True)
try:
await _write_summary(path, transcript_lines, channel)
except Exception as exc:
print(f"Summary error: {exc!r}", file=sys.stderr, flush=True)
else:
print("No transcript captured — skipping summary.", flush=True)
try:
await client.close()
except Exception as exc:
print(f"client.close error: {exc!r}", file=sys.stderr, flush=True)
client_task.cancel()
try:
await client_task
except asyncio.CancelledError:
pass
except Exception as exc:
print(f"client_task shutdown error: {exc!r}", file=sys.stderr, flush=True)
async def _write_summary(
path: Path, lines: list[str], channel: discord.VoiceChannel
) -> None:
transcript = "\n".join(lines)
prompt = (
f"Channel: #{channel.name} in {channel.guild.name}\n\n"
f"Transcript:\n{transcript}"
)
options = sdk.ClaudeAgentOptions(
system_prompt=SUMMARY_SYSTEM_PROMPT,
permission_mode="bypassPermissions",
model="claude-haiku-4-5",
max_turns=2,
)
parts: list[str] = []
async with sdk.ClaudeSDKClient(options) as claude:
await claude.query(prompt)
async for msg in claude.receive_response():
if isinstance(msg, sdk.AssistantMessage):
for block in msg.content:
if isinstance(block, sdk.TextBlock) and block.text:
parts.append(block.text)
elif isinstance(msg, sdk.ResultMessage):
if msg.total_cost_usd:
print(f"Summary cost: ${msg.total_cost_usd:.4f}", flush=True)
summary = "".join(parts).strip()
if not summary:
print("Claude returned an empty summary.", flush=True)
return
with path.open("a", encoding="utf-8") as f:
f.write("\n---\n\n")
f.write(summary + "\n")
print("\n=== Meeting Summary ===\n", flush=True)
print(summary, flush=True)
print(f"\nFull transcript + summary saved to: {path}", flush=True)
def main() -> None:
p = argparse.ArgumentParser(
description="Live meeting transcription for a Discord server voice channel."
)
p.add_argument("channel_id", type=int, help="Server voice channel ID")
p.add_argument(
"--stt",
default=os.environ.get("DISCLI_STT", "deepgram"),
choices=["deepgram", "openai"],
help="STT provider (default: deepgram; env: DISCLI_STT)",
)
args = p.parse_args()
try:
asyncio.run(run(args.channel_id, args.stt))
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()